From e20960312ce3e44287203d080f474390fc187792 Mon Sep 17 00:00:00 2001 From: swang22 Date: Thu, 22 Oct 2026 15:16:29 -0400 Subject: [PATCH 1/2] Sync HOPE agent-safe holistic workflow Synchronize model-aware HOPE MCP logic and integration tests from HOPE.jl 8126f0f. Register reconnect-safe holistic status and optional solver handling while retaining PowerMCP ownership of its MCP 2 transport and packaging. --- HOPE/README.md | 24 + HOPE/SYNCED_FROM_HOPE.yml | 5 + HOPE/src/hope_mcp_server/core.py | 889 ++++++++++++++++-- HOPE/src/hope_mcp_server/server.py | 27 +- HOPE/tests/test_core.py | 33 + HOPE/tests/test_holistic_agent_integration.py | 101 ++ ...test_holistic_agent_md_july_integration.py | 218 +++++ HOPE/tests/test_path_traversal.py | 78 +- 8 files changed, 1251 insertions(+), 124 deletions(-) create mode 100644 HOPE/SYNCED_FROM_HOPE.yml create mode 100644 HOPE/tests/test_holistic_agent_integration.py create mode 100644 HOPE/tests/test_holistic_agent_md_july_integration.py diff --git a/HOPE/README.md b/HOPE/README.md index 29053842..e16b88d9 100644 --- a/HOPE/README.md +++ b/HOPE/README.md @@ -29,6 +29,7 @@ Claude/local full-access server: - `hope_nodal_prices` - `hope_compare_cases` - `hope_run_holistic` +- `hope_holistic_status` - `hope_run_erec` - `hope_rep_day_audit` - `hope_aggregation_audit` @@ -139,6 +140,29 @@ Both servers accept: - prefixed paths such as `ModelCases/USA_64zone_GTEP_case` - the legacy alias `md_gtep_clean` +Case discovery uses `HOPE_MODELCASES_PATH` when it is set and otherwise falls +back to `/ModelCases`. + +## Agent-safe GTEP-to-PCM runs + +`hope_run_holistic(gtep_case_id, pcm_case_id, request_id, solver)` copies both +source cases into an isolated per-request workspace before launching Julia. +Existing outputs are excluded. Repeating the same request ID with identical +inputs returns the existing run, while conflicting reuse is rejected. Poll +`hope_holistic_status(request_id)` for reconnect-safe state and artifact paths. + +Set `HOPE_AGENT_RUNS_ROOT` to move the persistent workspaces from their default +`/artifacts/agent_runs` location. Set +`HOPE_MODELCASES_PATH` when ModelCases is maintained as a separate checkout. + +## Upstream synchronization + +The model-aware `core.py` and opt-in integration tests are synchronized from +`HOPE-Model-Project/HOPE.jl/tools/hope_mcp_server`. PowerMCP retains ownership +of its MCP SDK 2 transport entrypoints, packaging, lockfile, documentation, and +layout-specific unit tests. `SYNCED_FROM_HOPE.yml` records the exact upstream +HOPE commit represented by this downstream copy. + ## Local run Full local server: diff --git a/HOPE/SYNCED_FROM_HOPE.yml b/HOPE/SYNCED_FROM_HOPE.yml new file mode 100644 index 00000000..fa632f5e --- /dev/null +++ b/HOPE/SYNCED_FROM_HOPE.yml @@ -0,0 +1,5 @@ +schema_version: 1 +upstream_repository: https://github.com/HOPE-Model-Project/HOPE.jl +upstream_path: tools/hope_mcp_server +upstream_commit: 8126f0f6b83515f818e4c8f73b5e10e9286da68a +sync_manifest: tools/hope_mcp_server/powermcp_sync.yml diff --git a/HOPE/src/hope_mcp_server/core.py b/HOPE/src/hope_mcp_server/core.py index 451b3dba..49854c67 100644 --- a/HOPE/src/hope_mcp_server/core.py +++ b/HOPE/src/hope_mcp_server/core.py @@ -1,7 +1,10 @@ from __future__ import annotations import csv +import hashlib +import json import os +import re import shutil import socket import subprocess @@ -15,32 +18,27 @@ import yaml -DEFAULT_HOPE_REPO_ROOT = Path("/Users/qianzhang/Documents/GitHub/HOPE") +# The MCP package lives at /tools/hope_mcp_server/src/hope_mcp_server. +# Keep the environment override for installed deployments, but make a source +# checkout work on every developer machine without a user-specific path. +DEFAULT_HOPE_REPO_ROOT = Path(__file__).resolve().parents[4] DEFAULT_JULIA_COMMAND = "julia" - -def _hope_setting(env_var: str, config_key: str, default: Any) -> Any: - """Resolve a HOPE setting in order: env var, powermcp config, hardcoded default. - - The powermcp import is wrapped in try/except so HOPE stays runnable standalone - (e.g. ``python -m hope_mcp_server``) without powermcp installed; on any failure - we fall back to the historical hardcoded default. - """ - v = os.environ.get(env_var) - if v: - return v - try: - from powermcp.config import get_path - - return get_path("hope", config_key, must_exist=False) - except Exception: - return default - DEFAULT_CASE_ID = "md_gtep_clean" LEGACY_CASE_ALIASES = { DEFAULT_CASE_ID: "MD_GTEP_clean_case", } +HOLISTIC_REQUEST_SCHEMA_VERSION = 1 +HOLISTIC_OUTPUT_DIR_NAMES = { + "output", + "output_holistic", + "plot_output", + "backup", + "debug_report", +} +HOLISTIC_REQUEST_ID_PATTERN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$") + BOOLEAN_SETTING_KEYS = ( "resource_aggregation", "endogenous_rep_day", @@ -69,17 +67,18 @@ def success_result(**payload: Any) -> dict[str, Any]: def get_repo_root() -> Path: - return Path( - _hope_setting("HOPE_REPO_ROOT", "repo_root", str(DEFAULT_HOPE_REPO_ROOT)) - ).expanduser() + return Path(os.environ.get("HOPE_REPO_ROOT", str(DEFAULT_HOPE_REPO_ROOT))).expanduser() def configured_julia_command() -> str: - return _hope_setting("HOPE_JULIA_BIN", "julia_bin", DEFAULT_JULIA_COMMAND) + return os.environ.get("HOPE_JULIA_BIN", DEFAULT_JULIA_COMMAND) def modelcases_root(repo_root: Path) -> Path: - return repo_root / "ModelCases" + configured = os.environ.get("HOPE_MODELCASES_PATH") + if configured: + return Path(configured).expanduser().resolve() + return (repo_root / "ModelCases").resolve() def settings_path_for_case(case_path: Path) -> Path: @@ -129,10 +128,7 @@ def setup_command(repo_root: Path, julia_command: str) -> str: def validate_julia_command(repo_root: Path) -> tuple[str | None, dict[str, Any] | None]: - julia_command = configured_julia_command() - # An explicit julia binary (from HOPE_JULIA_BIN or powermcp config) is anything - # other than the bare default command, which routes through the PATH lookup below. - julia_env = julia_command if julia_command != DEFAULT_JULIA_COMMAND else None + julia_env = os.environ.get("HOPE_JULIA_BIN") if julia_env: julia_path = Path(julia_env).expanduser() if not julia_path.is_file(): @@ -233,6 +229,106 @@ def read_yaml(path: Path) -> dict[str, Any]: return data +def holistic_runs_root(repo_root: Path) -> Path: + configured = os.environ.get("HOPE_AGENT_RUNS_ROOT") + if configured: + return Path(configured).expanduser().resolve() + return (repo_root / "artifacts" / "agent_runs").resolve() + + +def normalize_holistic_request_id( + request_id: str | None, +) -> tuple[str | None, dict[str, Any] | None]: + value = str(request_id).strip() if request_id is not None else uuid.uuid4().hex + if not HOLISTIC_REQUEST_ID_PATTERN.fullmatch(value): + return None, error_result( + "invalid_request_id", + "request_id must be 1-128 characters using only letters, numbers, '.', '_', or '-'.", + request_id=value, + ) + return value, None + + +def _is_holistic_output_dir_name(name: str) -> bool: + return name in HOLISTIC_OUTPUT_DIR_NAMES or name.startswith("output_backup") + + +def _case_input_files(case_path: Path) -> list[Path]: + case_root = case_path.resolve() + settings_path = settings_path_for_case(case_path) + settings = read_yaml(settings_path) + data_case = str(settings.get("DataCase", "")).strip() + if not data_case: + raise ValueError(f"DataCase is missing or blank in {settings_path}") + data_root = (case_path / data_case).resolve() + try: + data_root.relative_to(case_root) + except ValueError as exc: + raise ValueError(f"DataCase must remain inside the case directory: {data_root}") from exc + if not data_root.is_dir(): + raise ValueError(f"DataCase directory does not exist: {data_root}") + roots = [(case_path / "Settings").resolve(), data_root] + + files: set[Path] = set() + for root in roots: + if not root.is_dir(): + continue + for path in root.rglob("*"): + if path.is_file() and not any( + _is_holistic_output_dir_name(part) for part in path.relative_to(case_root).parts + ): + files.add(path) + return sorted(files, key=lambda path: path.relative_to(case_root).as_posix()) + + +def holistic_case_input_fingerprint(case_path: Path) -> dict[str, Any]: + case_root = case_path.resolve() + digest = hashlib.sha256() + files = _case_input_files(case_path) + for path in files: + relative = path.relative_to(case_root).as_posix() + file_digest = hashlib.sha256(path.read_bytes()).hexdigest() + digest.update(relative.encode("utf-8")) + digest.update(b"\0") + digest.update(file_digest.encode("ascii")) + digest.update(b"\n") + return { + "sha256": digest.hexdigest(), + "file_count": len(files), + } + + +def _canonical_payload_hash(payload: dict[str, Any]) -> str: + encoded = json.dumps(payload, sort_keys=True, separators=(",", ":")).encode("utf-8") + return hashlib.sha256(encoded).hexdigest() + + +def _write_yaml_atomic(path: Path, payload: dict[str, Any]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + temporary = path.with_name(f".{path.name}.{uuid.uuid4().hex}.tmp") + try: + with temporary.open("w", encoding="utf-8", newline="\n") as handle: + yaml.safe_dump(payload, handle, sort_keys=False) + os.replace(temporary, path) + finally: + if temporary.exists(): + temporary.unlink() + + +def _read_holistic_manifest(path: Path) -> dict[str, Any]: + try: + return read_yaml(path) + except (OSError, ValueError, yaml.YAMLError) as exc: + raise ValueError(f"Invalid holistic run manifest {path}: {exc}") from exc + + +def _update_holistic_manifest(path: Path, **changes: Any) -> dict[str, Any]: + manifest = _read_holistic_manifest(path) + manifest.update(changes) + _write_yaml_atomic(path, manifest) + return manifest + + def output_dir_for_case(case_path: Path) -> Path: return case_path / "output" @@ -279,9 +375,15 @@ class _Job: include_output_summary: bool = False cancel_requested: bool = False cancelled_at: float | None = None + request_id: str = "" + workspace_path: str = "" + manifest_path: str = "" + output_case_path: str = "" _jobs: dict[str, _Job] = {} +_holistic_request_jobs: dict[str, str] = {} +_holistic_request_lock = threading.Lock() def _stream_lines(stream: Any, target: list[str]) -> None: @@ -296,6 +398,10 @@ def _launch_job( *, job_kind: str = "generic", include_output_summary: bool = False, + request_id: str = "", + workspace_path: str = "", + manifest_path: str = "", + output_case_path: str = "", ) -> str: job_id = uuid.uuid4().hex[:8] @@ -316,6 +422,10 @@ def _launch_job( case_id=case_id, job_kind=job_kind, include_output_summary=include_output_summary, + request_id=request_id, + workspace_path=workspace_path, + manifest_path=manifest_path, + output_case_path=output_case_path, ) threading.Thread(target=_stream_lines, args=(process.stdout, job.stdout_lines), daemon=True).start() threading.Thread(target=_stream_lines, args=(process.stderr, job.stderr_lines), daemon=True).start() @@ -325,10 +435,9 @@ def _launch_job( def _build_julia_process_env() -> dict[str, str]: # Inherit the current process environment, then preserve JULIA_DEPOT_PATH - # when Claude Desktop config (or powermcp config) points Julia to a - # policy-trusted depot. + # when Claude Desktop config points Julia to a policy-trusted depot. proc_env = os.environ.copy() - julia_depot = _hope_setting("JULIA_DEPOT_PATH", "julia_depot_path", "") + julia_depot = os.environ.get("JULIA_DEPOT_PATH") if julia_depot: proc_env["JULIA_DEPOT_PATH"] = julia_depot return proc_env @@ -361,6 +470,16 @@ def _job_stdout(job: _Job) -> str: return "\n".join(job.stdout_lines).strip() +def _job_context(job: _Job) -> dict[str, Any]: + if not job.request_id: + return {} + return { + "request_id": job.request_id, + "workspace_path": job.workspace_path, + "manifest_path": job.manifest_path, + } + + def looks_like_missing_hope_dependencies(stdout: str, stderr: str) -> bool: combined = f"{stdout}\n{stderr}" markers = ( @@ -410,6 +529,166 @@ def build_run_command(repo_root: Path, julia_bin: str, case_path: Path) -> list[ ] +def build_holistic_run_command( + repo_root: Path, + julia_bin: str, + gtep_case_path: Path, + pcm_case_path: Path, + manifest_path: Path, + solver: str | None, +) -> list[str]: + return [ + *build_project_julia_command(repo_root, julia_bin), + str(repo_root / "tools" / "repo_utils" / "run_holistic_agent_job.jl"), + str(gtep_case_path), + str(pcm_case_path), + str(manifest_path), + solver or "", + ] + + +_OPTIONAL_SOLVER_PACKAGES = { + "gurobi": "Gurobi", + "cplex": "CPLEX", + "scip": "SCIP", +} + + +def build_optional_solver_probe_command( + repo_root: Path, + julia_bin: str, + package_name: str, +) -> list[str]: + """Build a lightweight Julia package-discovery probe without loading HOPE.""" + return [ + *build_project_julia_command(repo_root, julia_bin), + "-e", + ( + "pkg = ARGS[1]; " + "path = Base.find_package(pkg); " + "sep = Sys.iswindows() ? \";\" : \":\"; " + "println(\"DEPOT_PATH=\" * join(DEPOT_PATH, sep)); " + "println(\"LOAD_PATH=\" * join(Base.load_path(), sep)); " + "println(\"PACKAGE_PATH=\" * (path === nothing ? \"\" : path)); " + "exit(path === nothing ? 3 : 0)" + ), + package_name, + ] + + +def _subprocess_error_output(value: Any) -> str: + if value is None: + return "" + if isinstance(value, bytes): + return value.decode(errors="replace") + return str(value) + + +def check_optional_solvers_available( + repo_root: Path, + julia_bin: str, + solver_names: list[str], + timeout_seconds: float = 60.0, +) -> dict[str, Any] | None: + """Return a structured error if an optional solver wrapper cannot be resolved.""" + normalized_solvers = dict.fromkeys( + str(name).strip().lower() for name in solver_names if str(name).strip() + ) + for solver_name in normalized_solvers: + package_name = _OPTIONAL_SOLVER_PACKAGES.get(solver_name) + if package_name is None: + continue + + command = build_optional_solver_probe_command(repo_root, julia_bin, package_name) + proc_env = _build_julia_process_env() + try: + completed = subprocess.run( + command, + cwd=repo_root, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + env=proc_env, + timeout=timeout_seconds, + ) + except subprocess.TimeoutExpired as exc: + return error_result( + "optional_solver_probe_failed", + ( + f"Timed out after {timeout_seconds:g} seconds while checking whether " + f"{package_name}.jl is visible to the Julia environment used by HOPE." + ), + solver=solver_name, + package=package_name, + repo_root=str(repo_root), + julia_bin=julia_bin, + julia_depot_path=proc_env.get("JULIA_DEPOT_PATH"), + timeout_seconds=timeout_seconds, + stdout=_subprocess_error_output(exc.stdout), + stderr=_subprocess_error_output(exc.stderr), + command=command, + ) + except (OSError, subprocess.SubprocessError) as exc: + return error_result( + "optional_solver_probe_failed", + f"Could not check whether {package_name}.jl is visible to Julia: {exc}", + solver=solver_name, + package=package_name, + repo_root=str(repo_root), + julia_bin=julia_bin, + julia_depot_path=proc_env.get("JULIA_DEPOT_PATH"), + command=command, + ) + + stdout = completed.stdout or "" + stderr = completed.stderr or "" + parsed = _parse_key_value_lines(stdout) + depot = proc_env.get("JULIA_DEPOT_PATH") or parsed.get("DEPOT_PATH", "") + common = { + "solver": solver_name, + "package": package_name, + "repo_root": str(repo_root), + "julia_bin": julia_bin, + "julia_depot_path": depot or None, + "load_path": parsed.get("LOAD_PATH"), + "package_path": parsed.get("PACKAGE_PATH") or None, + "command": command, + } + if completed.returncode == 0: + continue + + if completed.returncode == 3: + install_expression = ( + f'import Pkg; Pkg.activate(); Pkg.add("{package_name}")' + ) + install_command = f"julia -e '{install_expression}'" + install_command_powershell = f'& "{julia_bin}" -e \'{install_expression}\'' + return error_result( + "optional_solver_unavailable", + ( + f"solver='{solver_name}' requested, but {package_name}.jl cannot be " + "found on the Julia load path used by HOPE jobs. Add it to Julia's " + "version environment using the same Julia binary and JULIA_DEPOT_PATH." + ), + **common, + install_command=install_command, + install_command_powershell=install_command_powershell, + ) + + return error_result( + "optional_solver_probe_failed", + ( + f"Julia exited with code {completed.returncode} while checking whether " + f"{package_name}.jl is visible. The HOPE job was not launched." + ), + **common, + exit_code=completed.returncode, + stdout=stdout, + stderr=stderr, + ) + return None + + def build_debug_solver_environment_command( repo_root: Path, julia_bin: str, @@ -544,14 +823,58 @@ def parse_storage_builds(output_dir: Path) -> list[dict[str, Any]]: return builds +def _first_csv_row(path: Path) -> dict[str, Any] | None: + if not path.is_file(): + return None + rows = read_csv_rows(path) + return rows[0] if rows else None + + +def _solver_status_counts(path: Path) -> dict[str, int]: + if not path.is_file(): + return {} + counts: dict[str, int] = {} + for row in read_csv_rows(path): + termination = (row.get("termination_status") or "UNKNOWN").strip() + primal = (row.get("primal_status") or "UNKNOWN").strip() + label = f"{termination} / {primal}" + counts[label] = counts.get(label, 0) + 1 + return counts + + +def parse_dart_output_summary(output_dir: Path) -> dict[str, Any]: + """Read compact DART acceptance, coverage, settlement, and solver summaries.""" + coverage_path = output_dir / "constraint_coverage.csv" + coverage = read_csv_rows(coverage_path) if coverage_path.is_file() else [] + enabled = [ + row for row in coverage + if str(row.get("enabled", "")).strip().lower() == "true" + ] + return { + "dart_acceptance": _first_csv_row(output_dir / "acceptance_summary.csv"), + "dart_settlement": _first_csv_row(output_dir / "settlement_summary.csv"), + "dart_constraint_coverage": { + "enabled": len(enabled), + "total": len(coverage), + } if coverage else None, + "dart_solver_status_counts": { + "day_ahead": _solver_status_counts(output_dir / "day_ahead_solver.csv"), + "real_time": _solver_status_counts(output_dir / "real_time_solver.csv"), + }, + } + + def build_output_summary_payload(case_id: str, case_path: Path) -> dict[str, Any]: output_dir = output_dir_for_case(case_path) output_files = list_top_level_output_csvs(output_dir) total_system_cost, zone_costs = parse_system_cost_summary(output_dir) + settings = read_yaml(settings_path_for_case(case_path)) + model_mode = str(settings.get("model_mode", "")).upper() - return { + payload = { "case_id": case_id, "case_path": str(case_path), + "model_mode": model_mode, "output_path": str(output_dir), "output_exists": output_dir.is_dir(), "available_output_files": output_files, @@ -561,7 +884,9 @@ def build_output_summary_payload(case_id: str, case_path: Path) -> dict[str, Any "new_generation_builds": parse_generation_builds(output_dir), "new_storage_builds": parse_storage_builds(output_dir), } - + if model_mode == "DART": + payload.update(parse_dart_output_summary(output_dir)) + return payload def hope_case_info(case_id: str = "md_gtep_clean") -> dict[str, Any]: case_path, error = resolve_case(case_id) @@ -654,6 +979,7 @@ def hope_job_status(job_id: str) -> dict[str, Any]: stdout_tail = _job_stdout_tail(job) stdout = _job_stdout(job) stderr = _job_stderr(job) + context = _job_context(job) if exit_code is None: return success_result( @@ -662,9 +988,17 @@ def hope_job_status(job_id: str) -> dict[str, Any]: elapsed_seconds=elapsed, stdout_tail=stdout_tail, message=f"Job is still running ({elapsed}s elapsed). Call hope_job_status again to check.", + **context, ) if job.cancel_requested: + _record_holistic_job_status( + job, + "CANCELLED", + stdout=stdout, + stderr=stderr, + exit_code=exit_code, + ) return success_result( job_id=job_id, status="cancelled", @@ -673,10 +1007,18 @@ def hope_job_status(job_id: str) -> dict[str, Any]: stdout_tail=stdout_tail, stderr=stderr, message="Job was cancelled in this MCP session.", + **context, ) # Job finished if exit_code != 0: + _record_holistic_job_status( + job, + "FAILED", + stdout=stdout, + stderr=stderr, + exit_code=exit_code, + ) stdout_joined = stdout if job.job_kind == "debug_probe": return error_result( @@ -689,6 +1031,7 @@ def hope_job_status(job_id: str) -> dict[str, Any]: stdout=stdout, stderr=stderr, parsed_stdout=_parse_key_value_lines(stdout), + **context, ) if looks_like_applocker_block(stdout_joined, stderr): return error_result( @@ -705,6 +1048,7 @@ def hope_job_status(job_id: str) -> dict[str, Any]: elapsed_seconds=elapsed, stdout_tail=stdout_tail, stderr=stderr, + **context, ) if looks_like_missing_hope_dependencies(stdout_joined, stderr): repo_root = get_repo_root() @@ -718,6 +1062,7 @@ def hope_job_status(job_id: str) -> dict[str, Any]: stdout_tail=stdout_tail, stderr=stderr, setup_command=setup_command(repo_root, julia_bin), + **context, ) return error_result( "job_failed", @@ -727,15 +1072,26 @@ def hope_job_status(job_id: str) -> dict[str, Any]: elapsed_seconds=elapsed, stdout_tail=stdout_tail, stderr=stderr, + **context, ) + manifest = _record_holistic_job_status( + job, + "PUBLISHED", + stdout=stdout, + stderr=stderr, + exit_code=exit_code, + ) result = success_result( job_id=job_id, status="done", exit_code=exit_code, elapsed_seconds=elapsed, stdout_tail=stdout_tail, + **context, ) + if manifest is not None: + result["result"] = manifest.get("result") if job.job_kind == "debug_probe": result["stdout"] = stdout result["stderr"] = stderr @@ -761,7 +1117,19 @@ def hope_cancel_job(job_id: str, timeout_seconds: float = 5.0) -> dict[str, Any] exit_code = job.process.poll() if exit_code is not None: - status = "cancelled" if job.cancel_requested else "done" + status = "cancelled" if job.cancel_requested else ("done" if exit_code == 0 else "failed") + if job.request_id: + _record_holistic_job_status( + job, + ( + "CANCELLED" + if job.cancel_requested + else ("PUBLISHED" if exit_code == 0 else "FAILED") + ), + stdout=_job_stdout(job), + stderr=_job_stderr(job), + exit_code=exit_code, + ) return success_result( job_id=job_id, status=status, @@ -774,6 +1142,7 @@ def hope_cancel_job(job_id: str, timeout_seconds: float = 5.0) -> dict[str, Any] if job.cancel_requested else "Job has already finished; nothing was cancelled." ), + **_job_context(job), ) job.cancel_requested = True @@ -789,6 +1158,13 @@ def hope_cancel_job(job_id: str, timeout_seconds: float = 5.0) -> dict[str, Any] job.cancelled_at = time.perf_counter() final_exit_code = job.process.poll() + _record_holistic_job_status( + job, + "CANCELLED", + stdout=_job_stdout(job), + stderr=_job_stderr(job), + exit_code=final_exit_code, + ) return success_result( job_id=job_id, @@ -803,6 +1179,7 @@ def hope_cancel_job(job_id: str, timeout_seconds: float = 5.0) -> dict[str, Any] if not forced_kill else "Job did not exit after terminate(); force-killed successfully." ), + **_job_context(job), ) @@ -968,6 +1345,24 @@ def hope_run_hope(case_id: str = "md_gtep_clean") -> dict[str, Any]: if julia_error is not None: return julia_error + try: + settings = read_yaml(settings_path_for_case(case_path)) + except (OSError, ValueError, yaml.YAMLError) as exc: + return error_result( + "invalid_case_settings", + str(exc), + case_id=case_id, + settings_path=str(settings_path_for_case(case_path)), + ) + solver_error = check_optional_solvers_available( + repo_root, + julia_bin, + [str(settings.get("solver", ""))], + ) + if solver_error is not None: + solver_error.update(case_id=case_id) + return solver_error + command = build_run_command(repo_root, julia_bin, case_path) job_id = _launch_job( command, @@ -999,6 +1394,8 @@ def hope_run_hope(case_id: str = "md_gtep_clean") -> dict[str, Any]: "network_model": (0, 1, 2, 3), "unit_commitment": (0, 1, 2), "debug": (0, 1, 2), + "clean_energy_policy": (0, 1, 2), + "save_postprocess_snapshot": (0, 1, 2), } _BINARY_SETTING_KEYS = ( @@ -1008,15 +1405,13 @@ def hope_run_hope(case_id: str = "md_gtep_clean") -> dict[str, Any]: "flexible_demand", "inv_dcs_bin", "summary_table", - "save_postprocess_snapshot", - "clean_energy_policy", "transmission_expansion", "transmission_loss", "write_shadow_prices", ) _SOLVER_OPTIONS = ("cbc", "clp", "highs", "scip", "gurobi", "cplex") -_MODEL_MODE_OPTIONS = ("GTEP", "PCM") +_MODEL_MODE_OPTIONS = ("GTEP", "PCM", "DART") # Known contradictions: (key_a, value_a, key_b, value_b, message) _CONTRADICTIONS = [ @@ -1129,14 +1524,37 @@ def _validate_settings_dict(settings: dict[str, Any]) -> list[str]: except (TypeError, ValueError): pass - # PCM-only flags in GTEP mode - model_mode = settings.get("model_mode", "") - if model_mode == "GTEP": - for key in ("unit_commitment", "write_shadow_prices"): + # Mode-specific compatibility checks. Julia's validate_hope remains the + # authoritative validator; these warnings provide fast MCP feedback. + model_mode = str(settings.get("model_mode", "")).upper() + if model_mode == "GTEP" and settings.get("unit_commitment", 0): + warnings.append( + "unit_commitment has no effect in GTEP mode (model_mode='GTEP')." + ) + if model_mode == "DART": + unavailable = { + "resource_aggregation": "resource aggregation", + "endogenous_rep_day": "representative days", + "external_rep_day": "representative days", + "flexible_demand": "flexible demand", + "clean_energy_policy": "clean-energy policy", + "carbon_policy": "carbon policy", + "planning_reserve_mode": "planning reserve", + "inv_dcs_bin": "investment decisions", + "transmission_expansion": "transmission expansion", + "save_postprocess_snapshot": "GTEP postprocessing snapshots", + } + for key, label in unavailable.items(): if settings.get(key, 0): - warnings.append( - f"{key}=1 has no effect in GTEP mode (model_mode='GTEP')." - ) + warnings.append(f"{key} enables {label}, which is unavailable in DART.") + if settings.get("network_model", 3) != 3: + warnings.append("DART requires network_model=3 (nodal PTDF).") + if settings.get("transmission_loss", 0) != 0: + warnings.append("DART requires transmission_loss=0.") + if settings.get("unit_commitment", 1) not in (0, 1): + warnings.append("DART unit_commitment must be 0 or 1.") + if settings.get("debug", 0) != 0: + warnings.append("DART rolling debug is unavailable; set debug=0.") # Nodal model without reference_bus if settings.get("network_model", 0) in (2, 3) and not settings.get("reference_bus"): @@ -1204,15 +1622,19 @@ def hope_read_output( if error is not None: return error - output_dir = output_dir_for_case(case_path) + output_dir = output_dir_for_case(case_path).resolve() # Allow subdirectory paths like "postprocess_snapshot/metadata.yml" - csv_path = (output_dir / filename).resolve() - if not csv_path.is_relative_to(output_dir.resolve()): + requested_path = Path(filename) + try: + csv_path = (output_dir / requested_path).resolve() + csv_path.relative_to(output_dir) + except (OSError, ValueError): return error_result( "path_traversal", - "filename must refer to a path inside the case output directory.", + "Requested output file must remain inside the case output directory.", case_id=case_id, requested_file=filename, + output_path=str(output_dir), ) if not csv_path.is_file(): available = list_top_level_output_csvs(output_dir) @@ -1508,54 +1930,366 @@ def hope_compare_cases(case_ids: list[str]) -> dict[str, Any]: # Holistic two-stage workflow # --------------------------------------------------------------------------- +def _holistic_copy_ignore(_directory: str, names: list[str]) -> set[str]: + return {name for name in names if _is_holistic_output_dir_name(name)} + + +def _holistic_manifest_response(manifest: dict[str, Any], *, reused: bool) -> dict[str, Any]: + return success_result( + request_id=manifest.get("request_id"), + job_id=manifest.get("job_id"), + status=str(manifest.get("status", "unknown")).lower(), + reused=reused, + workspace_path=manifest.get("workspace_path"), + manifest_path=manifest.get("manifest_path"), + gtep_case_id=manifest.get("gtep_case_id"), + pcm_case_id=manifest.get("pcm_case_id"), + solver=manifest.get("solver"), + request_sha256=manifest.get("request_sha256"), + result=manifest.get("result"), + error=manifest.get("error"), + ) + + +def _existing_holistic_request_response( + workspace_path: Path, + manifest_path: Path, + request_id: str, + request_sha256: str, +) -> dict[str, Any] | None: + """Return an existing request response or conflict error, if one exists.""" + if not workspace_path.exists(): + return None + if not manifest_path.is_file(): + return error_result( + "holistic_workspace_conflict", + f"Workspace already exists without a valid run manifest: {workspace_path}", + request_id=request_id, + workspace_path=str(workspace_path), + ) + try: + existing = _read_holistic_manifest(manifest_path) + except ValueError as exc: + return error_result( + "invalid_holistic_manifest", + str(exc), + request_id=request_id, + manifest_path=str(manifest_path), + ) + if existing.get("request_sha256") != request_sha256: + return error_result( + "request_id_conflict", + "request_id already belongs to a different GTEP-PCM request or source version.", + request_id=request_id, + existing_request_sha256=existing.get("request_sha256"), + requested_sha256=request_sha256, + ) + return _holistic_manifest_response(existing, reused=True) + + +def _record_holistic_job_status( + job: _Job, + status: str, + *, + stdout: str = "", + stderr: str = "", + exit_code: int | None = None, +) -> dict[str, Any] | None: + if not job.manifest_path: + return None + manifest_path = Path(job.manifest_path) + if not manifest_path.is_file(): + return None + manifest = _read_holistic_manifest(manifest_path) + if manifest.get("status") == status: + return manifest + changes: dict[str, Any] = {"status": status, "updated_at_unix": time.time()} + if exit_code is not None: + changes["exit_code"] = exit_code + if status == "PUBLISHED": + parsed = _parse_key_value_lines(stdout) + changes["result"] = { + "gtep_case_path": parsed.get("HOPE_GTEP_CASE_PATH"), + "pcm_case_path": parsed.get("HOPE_PCM_CASE_PATH", job.output_case_path), + "pcm_output_path": parsed.get("HOPE_PCM_OUTPUT_PATH"), + "handoff_valid": parsed.get("HOPE_HANDOFF_VALID") == "true", + "transfer_valid": parsed.get("HOPE_TRANSFER_VALID") == "true", + "coverage_all_passed": ( + parsed.get("HOPE_COVERAGE_ALL_PASSED") == "true" + if "HOPE_COVERAGE_ALL_PASSED" in parsed + else None + ), + } + elif status in ("FAILED", "CANCELLED"): + changes["error"] = { + "stderr": stderr, + "stdout_tail": last_nonempty_lines(stdout), + } + return _update_holistic_manifest(manifest_path, **changes) + + +def hope_holistic_status(request_id: str) -> dict[str, Any]: + """Return retry-safe status for a holistic request, including after an MCP reconnect.""" + normalized, error = normalize_holistic_request_id(request_id) + if error is not None: + return error + assert normalized is not None + + job_id = _holistic_request_jobs.get(normalized) + if job_id and job_id in _jobs: + result = hope_job_status(job_id) + result["request_id"] = normalized + return result + + manifest_path = holistic_runs_root(get_repo_root()) / normalized / "run_manifest.yml" + if not manifest_path.is_file(): + return error_result( + "holistic_request_not_found", + f"No holistic request found with request_id '{normalized}'.", + request_id=normalized, + ) + try: + manifest = _read_holistic_manifest(manifest_path) + except ValueError as exc: + return error_result( + "invalid_holistic_manifest", + str(exc), + request_id=normalized, + manifest_path=str(manifest_path), + ) + return _holistic_manifest_response(manifest, reused=True) + + def hope_run_holistic( gtep_case_id: str, pcm_case_id: str, + request_id: str | None = None, + solver: str | None = None, ) -> dict[str, Any]: - """ - Run the two-stage GTEP→PCM holistic workflow as a background job. - Stage 1 solves capacity expansion (GTEP); Stage 2 fixes the built fleet and runs PCM. - Returns a job_id immediately. Poll with hope_job_status. - """ + """Launch an isolated, idempotent GTEP-to-PCM workflow for an agent.""" repo_root = get_repo_root() gtep_path, err = resolve_case(gtep_case_id) if err is not None: return error_result("invalid_gtep_case", err.get("message", ""), case_id=gtep_case_id) - pcm_path, err = resolve_case(pcm_case_id) if err is not None: return error_result("invalid_pcm_case", err.get("message", ""), case_id=pcm_case_id) + gtep_settings = read_yaml(settings_path_for_case(gtep_path)) + pcm_settings = read_yaml(settings_path_for_case(pcm_path)) + gtep_mode = str(gtep_settings.get("model_mode", "")).strip().upper() + pcm_mode = str(pcm_settings.get("model_mode", "")).strip().upper() + if gtep_mode != "GTEP" or pcm_mode != "PCM": + return error_result( + "invalid_holistic_model_modes", + "The holistic workflow requires a GTEP source case and a PCM source case.", + gtep_case_id=gtep_case_id, + gtep_model_mode=gtep_mode, + pcm_case_id=pcm_case_id, + pcm_model_mode=pcm_mode, + ) + + selected_solver = str(solver).strip().lower() if solver is not None else None + if selected_solver is not None and selected_solver not in _SOLVER_OPTIONS: + return error_result( + "invalid_solver", + f"Unsupported solver '{selected_solver}'.", + solver=selected_solver, + allowed_solvers=list(_SOLVER_OPTIONS), + ) + + normalized_request_id, request_error = normalize_holistic_request_id(request_id) + if request_error is not None: + return request_error + assert normalized_request_id is not None + julia_bin, julia_error = validate_julia_command(repo_root) if julia_error is not None: return julia_error - julia_script = "using HOPE; HOPE.run_hope_holistic(ARGS[1], ARGS[2])" - command = [ - *build_project_julia_command(repo_root, julia_bin), - "-e", - julia_script, - str(gtep_path), - str(pcm_path), - ] + try: + gtep_fingerprint = holistic_case_input_fingerprint(gtep_path) + pcm_fingerprint = holistic_case_input_fingerprint(pcm_path) + except (OSError, ValueError, yaml.YAMLError) as exc: + return error_result( + "invalid_holistic_inputs", + str(exc), + gtep_case_id=gtep_case_id, + pcm_case_id=pcm_case_id, + ) + request_payload = { + "schema_version": HOLISTIC_REQUEST_SCHEMA_VERSION, + "operation": "GTEP-PCM", + "gtep_case_id": gtep_case_id, + "pcm_case_id": pcm_case_id, + "solver": selected_solver, + "gtep_source_sha256": gtep_fingerprint["sha256"], + "pcm_source_sha256": pcm_fingerprint["sha256"], + } + request_sha256 = _canonical_payload_hash(request_payload) + workspace_path = holistic_runs_root(repo_root) / normalized_request_id + manifest_path = workspace_path / "run_manifest.yml" + + # Existing requests remain queryable even if the local optional-solver + # installation changes after launch. Probe only when a new workspace is + # actually required. + with _holistic_request_lock: + existing_response = _existing_holistic_request_response( + workspace_path, + manifest_path, + normalized_request_id, + request_sha256, + ) + if existing_response is not None: + return existing_response - # Tag job with both case IDs - job_id = _launch_job( - command, + requested_solvers = ( + [selected_solver] + if selected_solver is not None + else [str(gtep_settings.get("solver", "")), str(pcm_settings.get("solver", ""))] + ) + solver_error = check_optional_solvers_available( repo_root, - case_id=gtep_case_id, - job_kind="hope_run_holistic", - include_output_summary=True, + julia_bin, + requested_solvers, ) + if solver_error is not None: + solver_error.update(gtep_case_id=gtep_case_id, pcm_case_id=pcm_case_id) + return solver_error + + with _holistic_request_lock: + # Another caller can reserve the same request while the probe runs. + existing_response = _existing_holistic_request_response( + workspace_path, + manifest_path, + normalized_request_id, + request_sha256, + ) + if existing_response is not None: + return existing_response + + workspace_path.parent.mkdir(parents=True, exist_ok=True) + final_gtep = workspace_path / "gtep_case" + final_pcm = workspace_path / "pcm_case" + try: + workspace_path.mkdir() + except FileExistsError: + return error_result( + "holistic_workspace_conflict", + "Another process reserved this request_id while the request was being prepared.", + request_id=normalized_request_id, + workspace_path=str(workspace_path), + ) + except OSError as exc: + return error_result( + "holistic_workspace_setup_failed", + str(exc), + request_id=normalized_request_id, + workspace_path=str(workspace_path), + ) + manifest = { + **request_payload, + "request_id": normalized_request_id, + "request_sha256": request_sha256, + "status": "CREATED", + "created_at_unix": time.time(), + "updated_at_unix": time.time(), + "workspace_path": str(workspace_path), + "manifest_path": str(manifest_path), + "gtep_source_path": str(gtep_path), + "pcm_source_path": str(pcm_path), + "gtep_case_path": str(final_gtep), + "pcm_case_path": str(final_pcm), + "gtep_source_fingerprint": gtep_fingerprint, + "pcm_source_fingerprint": pcm_fingerprint, + } + _write_yaml_atomic(manifest_path, manifest) + try: + shutil.copytree(gtep_path, final_gtep, ignore=_holistic_copy_ignore) + shutil.copytree(pcm_path, final_pcm, ignore=_holistic_copy_ignore) + if holistic_case_input_fingerprint(final_gtep) != gtep_fingerprint: + raise RuntimeError("GTEP source changed while preparing the isolated workspace.") + if holistic_case_input_fingerprint(final_pcm) != pcm_fingerprint: + raise RuntimeError("PCM source changed while preparing the isolated workspace.") + _update_holistic_manifest( + manifest_path, + status="VALIDATED", + updated_at_unix=time.time(), + ) + except (OSError, RuntimeError, ValueError, yaml.YAMLError) as exc: + _update_holistic_manifest( + manifest_path, + status="FAILED", + updated_at_unix=time.time(), + error={"message": str(exc)}, + ) + return error_result( + "holistic_workspace_setup_failed", + str(exc), + request_id=normalized_request_id, + workspace_path=str(workspace_path), + ) + + isolated_gtep = workspace_path / "gtep_case" + isolated_pcm = workspace_path / "pcm_case" + command = build_holistic_run_command( + repo_root, + julia_bin, + isolated_gtep, + isolated_pcm, + manifest_path, + selected_solver, + ) + try: + job_id = _launch_job( + command, + repo_root, + case_id=pcm_case_id, + job_kind="hope_run_holistic", + request_id=normalized_request_id, + workspace_path=str(workspace_path), + manifest_path=str(manifest_path), + output_case_path=str(isolated_pcm), + ) + except (OSError, subprocess.SubprocessError) as exc: + _update_holistic_manifest( + manifest_path, + status="FAILED", + updated_at_unix=time.time(), + error={"message": str(exc)}, + ) + return error_result( + "holistic_launch_failed", + str(exc), + request_id=normalized_request_id, + workspace_path=str(workspace_path), + manifest_path=str(manifest_path), + ) + + _holistic_request_jobs[normalized_request_id] = job_id + _update_holistic_manifest( + manifest_path, + status="RUNNING", + updated_at_unix=time.time(), + job_id=job_id, + command=command, + ) + return success_result( job_id=job_id, + request_id=normalized_request_id, + status="running", + reused=False, gtep_case_id=gtep_case_id, pcm_case_id=pcm_case_id, + solver=selected_solver, + request_sha256=request_sha256, + workspace_path=str(workspace_path), + manifest_path=str(manifest_path), message=( - f"Holistic GTEP→PCM run started (GTEP: '{gtep_case_id}', PCM: '{pcm_case_id}'). " - "Call hope_job_status with this job_id to check progress. " - "Stage 1 (GTEP) runs first, then Stage 2 (PCM) uses the fixed built fleet." + f"Isolated holistic GTEP-to-PCM run started for request '{normalized_request_id}'. " + "Call hope_holistic_status with this request_id; retries are idempotent." ), command=command, ) @@ -1862,7 +2596,7 @@ def _dashboard_launch_env(repo_root: Path, effective_port: int) -> dict[str, str continue if upper_key.startswith("UV_"): env.pop(key, None) - env["HOPE_MODELCASES_PATH"] = str(repo_root / "ModelCases") + env["HOPE_MODELCASES_PATH"] = str(modelcases_root(repo_root)) env["HOPE_DASHBOARD_PORT"] = str(effective_port) env["PYTHONUNBUFFERED"] = "1" return env @@ -1947,10 +2681,19 @@ def hope_open_dashboard( runner_script = dashboard_dir / "run_dashboard.py" default_port = 8050 app_label = "PCM Dashboard" - else: + elif model_mode == "GTEP": runner_script = dashboard_dir / "run_gtep_dashboard.py" default_port = 8051 app_label = "GTEP Dashboard" + else: + return error_result( + "dashboard_unsupported_model_mode", + f"No local dashboard is implemented for model_mode={model_mode}. " + "DART outputs remain available through hope_output_summary and hope_read_output.", + case_id=case_id, + model_mode=model_mode, + supported_model_modes=["GTEP", "PCM"], + ) if not runner_script.is_file(): return error_result( diff --git a/HOPE/src/hope_mcp_server/server.py b/HOPE/src/hope_mcp_server/server.py index faf0b74e..37353645 100644 --- a/HOPE/src/hope_mcp_server/server.py +++ b/HOPE/src/hope_mcp_server/server.py @@ -15,6 +15,7 @@ hope_debug_solver_environment_async, hope_compare_cases, hope_emission_compliance, + hope_holistic_status, hope_job_status, hope_nodal_prices, hope_open_dashboard, @@ -323,7 +324,7 @@ def hope_debug_solver_environment_async_tool( name="hope_run_hope", description=( "Launch a HOPE single-case run as a background job. Returns a job_id immediately. " - "Poll with hope_job_status until done. Works for both GTEP and PCM model modes. " + "Poll with hope_job_status until done. Works for GTEP, PCM, and DART model modes. " "Pass any ModelCases directory name as case_id." ), ) @@ -356,14 +357,32 @@ def hope_update_settings_tool( "Stage 1 solves capacity expansion (GTEP) to determine optimal new builds and retirements. " "Stage 2 fixes the resulting fleet and runs a full PCM (production cost model) " "to validate operational feasibility and compute hourly dispatch + LMPs. " - "Returns a job_id immediately. Poll with hope_job_status." + "Creates an isolated workspace and persistent manifest. Returns a request_id and job_id; " + "poll with hope_holistic_status. Reusing the same request_id is idempotent." ), ) def hope_run_holistic_tool( gtep_case_id: str, pcm_case_id: str, + request_id: str | None = None, + solver: str | None = None, ) -> dict[str, Any]: - return hope_run_holistic(gtep_case_id=gtep_case_id, pcm_case_id=pcm_case_id) + return hope_run_holistic( + gtep_case_id=gtep_case_id, + pcm_case_id=pcm_case_id, + request_id=request_id, + solver=solver, + ) + + @mcp.tool( + name="hope_holistic_status", + description=( + "Return the persistent status and artifact locations for a GTEP->PCM request. " + "Unlike job IDs, request IDs remain queryable after an MCP reconnect." + ), + ) + def hope_holistic_status_tool(request_id: str) -> dict[str, Any]: + return hope_holistic_status(request_id) @mcp.tool( name="hope_run_erec", @@ -392,7 +411,7 @@ def hope_run_erec_tool( description=( "Launch the local HOPE Dash dashboard for a completed case run and return its URL. " "Automatically picks the GTEP dashboard (port 8051) for GTEP cases and the PCM dashboard " - "(port 8050) for PCM cases. " + "(port 8050) for PCM cases. DART has no dashboard yet and returns an explicit unsupported-mode error. " "If a dashboard is already running on the target port, returns the existing URL immediately. " "After the browser URL opens, select the matching case from the dropdown in the UI. " "Requires Python with dash, plotly, pandas installed " diff --git a/HOPE/tests/test_core.py b/HOPE/tests/test_core.py index c98bbc21..b87f5c46 100644 --- a/HOPE/tests/test_core.py +++ b/HOPE/tests/test_core.py @@ -15,7 +15,9 @@ _Job, _jobs, build_debug_solver_environment_command, + build_optional_solver_probe_command, build_run_command, + check_optional_solvers_available, hope_cancel_job, hope_case_info, hope_debug_solver_environment, @@ -146,6 +148,7 @@ def test_full_server_keeps_run_tools_for_claude(self) -> None: self.assertIn("hope_cancel_job", tool_names) self.assertIn("hope_debug_solver_environment", tool_names) self.assertIn("hope_debug_solver_environment_async", tool_names) + self.assertIn("hope_holistic_status", tool_names) def test_read_only_server_accepts_configured_public_hostname(self) -> None: with mock.patch.dict( @@ -218,6 +221,36 @@ def test_debug_command_passes_dynamic_values_as_arguments(self) -> None: self.assertNotIn("$solver", script) self.assertEqual(command[script_index + 1 :], [str(case_path), "$solver"]) + def test_optional_solver_probe_does_not_load_hope(self) -> None: + command = build_optional_solver_probe_command(REPO_ROOT, JULIA_BIN, "Gurobi") + script = command[command.index("-e") + 1] + self.assertIn("Base.find_package(pkg)", script) + self.assertIn("Sys.iswindows()", script) + self.assertNotIn("using HOPE", script) + self.assertEqual(command[-1], "Gurobi") + + def test_missing_optional_solver_returns_actionable_error(self) -> None: + completed = subprocess.CompletedProcess( + args=[], + returncode=3, + stdout="DEPOT_PATH=/depot\nLOAD_PATH=/project:/version\nPACKAGE_PATH=\n", + stderr="", + ) + with mock.patch( + "hope_mcp_server.core.subprocess.run", + return_value=completed, + ): + result = check_optional_solvers_available( + REPO_ROOT, + JULIA_BIN, + ["gurobi"], + ) + self.assertIsNotNone(result) + assert result is not None + self.assertEqual(result["error_type"], "optional_solver_unavailable") + self.assertEqual(result["package"], "Gurobi") + self.assertIn("Pkg.add", result["install_command"]) + def test_missing_julia_path_returns_structured_error(self) -> None: with mock.patch.dict( "os.environ", diff --git a/HOPE/tests/test_holistic_agent_integration.py b/HOPE/tests/test_holistic_agent_integration.py new file mode 100644 index 00000000..754a766e --- /dev/null +++ b/HOPE/tests/test_holistic_agent_integration.py @@ -0,0 +1,101 @@ +from __future__ import annotations + +import os +import sys +import tempfile +import time +import unittest +import uuid +from pathlib import Path +from unittest import mock + +import yaml + +PACKAGE_ROOT = Path(__file__).resolve().parents[1] +REPO_ROOT = Path( + os.environ.get("HOPE_REPO_ROOT", Path(__file__).resolve().parents[3]) +).expanduser().resolve() +sys.path.insert(0, str(PACKAGE_ROOT / "src")) + +from hope_mcp_server.core import ( # noqa: E402 + _holistic_request_jobs, + _jobs, + hope_holistic_status, + hope_run_holistic, +) + + +@unittest.skipUnless( + os.environ.get("HOPE_RUN_HOLISTIC_AGENT_INTEGRATION") == "1", + "set HOPE_RUN_HOLISTIC_AGENT_INTEGRATION=1 to run the solver-backed agent test", +) +class HolisticAgentIntegrationTests(unittest.TestCase): + def test_restricted_gtep_pcm_request_survives_reconnect(self) -> None: + gtep_case = "MD_GTEP_restricted_linkage_case" + pcm_case = "MD_PCM_restricted_linkage_case" + for case_id in (gtep_case, pcm_case): + if not (REPO_ROOT / "ModelCases" / case_id).is_dir(): + self.skipTest(f"ModelCases/{case_id} is unavailable") + + request_id = f"agent-integration-{uuid.uuid4().hex}" + solver = os.environ.get("HOPE_HOLISTIC_AGENT_SOLVER", "highs") + timeout_seconds = float(os.environ.get("HOPE_HOLISTIC_AGENT_TIMEOUT", "900")) + + with tempfile.TemporaryDirectory() as run_root: + with mock.patch.dict( + "os.environ", + { + "HOPE_REPO_ROOT": str(REPO_ROOT), + "HOPE_AGENT_RUNS_ROOT": run_root, + }, + clear=False, + ): + launch = hope_run_holistic( + gtep_case, + pcm_case, + request_id=request_id, + solver=solver, + ) + self.assertTrue(launch["ok"], launch) + + deadline = time.monotonic() + timeout_seconds + while True: + status = hope_holistic_status(request_id) + state = status.get("status") + if state in ("done", "published", "failed", "cancelled"): + break + self.assertTrue(status["ok"], status) + if time.monotonic() >= deadline: + self.fail(f"Holistic request timed out: {status}") + time.sleep(2.0) + + self.assertTrue(status["ok"], status) + self.assertIn(state, ("done", "published")) + result = status["result"] + self.assertTrue(result["handoff_valid"]) + self.assertTrue(result["transfer_valid"]) + self.assertTrue(result["coverage_evaluated"]) + self.assertTrue(result["coverage_all_passed"]) + + coverage_path = Path(result["coverage_paths"]["coverage_yaml"]) + coverage = yaml.safe_load(coverage_path.read_text(encoding="utf-8")) + self.assertTrue(coverage["all_passed"]) + self.assertEqual(coverage["metrics"]["gtep_termination_status"], "OPTIMAL") + self.assertEqual(coverage["metrics"]["pcm_termination_status"], "OPTIMAL") + self.assertTrue(coverage["metrics"]["source_gtep_unchanged"]) + self.assertTrue(coverage["metrics"]["source_pcm_unchanged"]) + + _holistic_request_jobs.pop(request_id, None) + completed_job = _jobs.pop(launch["job_id"], None) + if completed_job is not None: + for stream in (completed_job.process.stdout, completed_job.process.stderr): + if stream is not None: + stream.close() + recovered = hope_holistic_status(request_id) + self.assertTrue(recovered["ok"], recovered) + self.assertEqual(recovered["status"], "published") + self.assertTrue(recovered["result"]["coverage_all_passed"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/HOPE/tests/test_holistic_agent_md_july_integration.py b/HOPE/tests/test_holistic_agent_md_july_integration.py new file mode 100644 index 00000000..bdab31bc --- /dev/null +++ b/HOPE/tests/test_holistic_agent_md_july_integration.py @@ -0,0 +1,218 @@ +from __future__ import annotations + +import os +import subprocess +import sys +import tempfile +import time +import unittest +import uuid +from pathlib import Path +from unittest import mock + +import yaml + +PACKAGE_ROOT = Path(__file__).resolve().parents[1] +REPO_ROOT = Path( + os.environ.get("HOPE_REPO_ROOT", Path(__file__).resolve().parents[3]) +).expanduser().resolve() +sys.path.insert(0, str(PACKAGE_ROOT / "src")) + +from hope_mcp_server.core import ( # noqa: E402 + _holistic_request_jobs, + _jobs, + hope_holistic_status, + hope_run_holistic, +) + + +def configured_modelcases_root() -> Path: + configured = os.environ.get("HOPE_MODELCASES_PATH") + return ( + Path(configured).expanduser().resolve() + if configured + else (REPO_ROOT / "ModelCases").resolve() + ) + + +@unittest.skipUnless( + os.environ.get("HOPE_RUN_HOLISTIC_AGENT_MD_JULY_INTEGRATION") == "1", + "set HOPE_RUN_HOLISTIC_AGENT_MD_JULY_INTEGRATION=1 to run the high-resolution agent test", +) +class MarylandJulyHolisticAgentIntegrationTests(unittest.TestCase): + def test_annual_gtep_july_pcm_request_is_agent_safe(self) -> None: + source_modelcases = configured_modelcases_root() + builder = ( + source_modelcases + / "tools" + / "md_transmission_case_related" + / "build_md_rps20_full8760_holistic_pair.py" + ) + required_cases = ( + "MD_GTEP_RPS20_4zone_full8760_public_case", + "MD_PCM_RPS20_transmission_full8760_public_case", + ) + if not builder.is_file(): + self.skipTest(f"Maryland validation builder is unavailable: {builder}") + for case_id in required_cases: + if not (source_modelcases / case_id).is_dir(): + self.skipTest(f"Required ModelCase is unavailable: {case_id}") + + solver = os.environ.get("HOPE_HOLISTIC_AGENT_MD_JULY_SOLVER", "gurobi") + month = int(os.environ.get("HOPE_HOLISTIC_AGENT_MD_JULY_MONTH", "7")) + timeout_seconds = float( + os.environ.get("HOPE_HOLISTIC_AGENT_MD_JULY_TIMEOUT", "3600") + ) + work_parent_value = os.environ.get("HOPE_AGENT_TEST_WORK_ROOT") + work_parent = ( + Path(work_parent_value).expanduser().resolve() + if work_parent_value + else None + ) + if work_parent is not None: + work_parent.mkdir(parents=True, exist_ok=True) + + request_id = f"md-rps20-july-{uuid.uuid4().hex}" + launch: dict | None = None + with tempfile.TemporaryDirectory( + prefix="hope-md-july-agent-", + dir=str(work_parent) if work_parent is not None else None, + ) as tmp: + temporary_root = Path(tmp) + generated_cases = temporary_root / "model-cases" + runs_root = temporary_root / "agent-runs" + + print( + f"Preparing annual-GTEP/month-{month}-PCM validation cases...", + flush=True, + ) + subprocess.run( + [ + sys.executable, + str(builder), + "--validation-month", + str(month), + "--validation-root", + str(generated_cases), + ], + cwd=REPO_ROOT, + check=True, + ) + + validation_slice = yaml.safe_load( + (generated_cases / "validation_slice.yml").read_text(encoding="utf-8") + ) + self.assertEqual(validation_slice["gtep_hours"], 8760) + self.assertEqual(validation_slice["pcm_month"], month) + self.assertGreater(validation_slice["pcm_hours"], 0) + if month == 7: + self.assertEqual(validation_slice["pcm_hours"], 744) + + with mock.patch.dict( + "os.environ", + { + "HOPE_REPO_ROOT": str(REPO_ROOT), + "HOPE_MODELCASES_PATH": str(generated_cases), + "HOPE_AGENT_RUNS_ROOT": str(runs_root), + }, + clear=False, + ): + try: + launch = hope_run_holistic( + "gtep_case", + "pcm_case", + request_id=request_id, + solver=solver, + ) + self.assertTrue(launch["ok"], launch) + self.assertFalse(launch["reused"]) + + deadline = time.monotonic() + timeout_seconds + next_progress = 0.0 + while True: + status = hope_holistic_status(request_id) + state = status.get("status") + if state in ("done", "published", "failed", "cancelled"): + break + self.assertTrue(status["ok"], status) + now = time.monotonic() + if now >= next_progress: + print( + "High-resolution holistic agent test status: " + f"{state}; elapsed={status.get('elapsed_seconds', 'unknown')}s", + flush=True, + ) + next_progress = now + 30.0 + if now >= deadline: + self.fail(f"Holistic request timed out: {status}") + time.sleep(5.0) + + self.assertTrue(status["ok"], status) + self.assertIn(state, ("done", "published"), status) + result = status["result"] + self.assertTrue(result["handoff_valid"]) + self.assertTrue(result["transfer_valid"]) + self.assertTrue(result["coverage_evaluated"]) + self.assertTrue(result["coverage_all_passed"]) + + coverage_path = Path(result["coverage_paths"]["coverage_yaml"]) + coverage = yaml.safe_load(coverage_path.read_text(encoding="utf-8")) + metrics = coverage["metrics"] + self.assertTrue(coverage["all_passed"]) + self.assertEqual(metrics["gtep_termination_status"], "OPTIMAL") + self.assertEqual(metrics["pcm_termination_status"], "OPTIMAL") + self.assertTrue(metrics["source_gtep_unchanged"]) + self.assertTrue(metrics["source_pcm_unchanged"]) + self.assertGreater(float(metrics["new_generation_mw"]), 0.0) + self.assertGreater( + float(metrics["pcm_new_generation_dispatch_mwh"]), 0.0 + ) + self.assertGreater( + float(metrics["operational_template_assets"]), 0.0 + ) + self.assertTrue(metrics["new_thermal_generation_all_uc"]) + self.assertGreater( + float(metrics["gtep_operating_reserve_mwh"]), 0.0 + ) + self.assertGreater( + float(metrics["pcm_operating_reserve_mwh"]), 0.0 + ) + self.assertLessEqual(float(metrics["pcm_load_shedding_mwh"]), 1e-6) + self.assertLessEqual(float(metrics["gtep_rps_slack_mwh"]), 1e-6) + self.assertLessEqual(float(metrics["pcm_rps_slack_mwh"]), 1e-6) + + _holistic_request_jobs.pop(request_id, None) + recovered = hope_holistic_status(request_id) + self.assertTrue(recovered["ok"], recovered) + self.assertEqual(recovered["status"], "published") + self.assertTrue(recovered["result"]["coverage_all_passed"]) + + repeated = hope_run_holistic( + "gtep_case", + "pcm_case", + request_id=request_id, + solver=solver, + ) + self.assertTrue(repeated["ok"], repeated) + self.assertTrue(repeated["reused"]) + self.assertEqual(repeated["status"], "published") + self.assertEqual(len(list(runs_root.iterdir())), 1) + finally: + _holistic_request_jobs.pop(request_id, None) + if launch is not None: + job = _jobs.pop(launch.get("job_id", ""), None) + if job is not None: + if job.process.poll() is None: + job.process.terminate() + try: + job.process.wait(timeout=10) + except subprocess.TimeoutExpired: + job.process.kill() + job.process.wait(timeout=10) + for stream in (job.process.stdout, job.process.stderr): + if stream is not None: + stream.close() + + +if __name__ == "__main__": + unittest.main() diff --git a/HOPE/tests/test_path_traversal.py b/HOPE/tests/test_path_traversal.py index 89ed71d0..89493fab 100644 --- a/HOPE/tests/test_path_traversal.py +++ b/HOPE/tests/test_path_traversal.py @@ -1,4 +1,5 @@ -"""Tests for CWE-22 path traversal mitigation in hope_read_output.""" +"""Regression tests for output-path containment in ``hope_read_output``.""" + from __future__ import annotations import csv @@ -14,65 +15,62 @@ from hope_mcp_server.core import hope_read_output # noqa: E402 -def _create_case_tree(tmp: Path) -> Path: - """Build a minimal ModelCases//output/ tree for testing.""" - case_dir = tmp / "ModelCases" / "test_case" +def _create_case_tree(root: Path) -> Path: + case_dir = root / "ModelCases" / "test_case" output_dir = case_dir / "output" output_dir.mkdir(parents=True) settings_dir = case_dir / "Settings" settings_dir.mkdir(parents=True) (settings_dir / "HOPE_model_settings.yml").write_text( - "model_mode: GTEP\nsolver: highs\n" + "model_mode: GTEP\nsolver: highs\n", + encoding="utf-8", ) - # Write a small CSV so legitimate reads succeed - csv_path = output_dir / "system_cost.csv" - with csv_path.open("w", newline="") as fh: - writer = csv.DictWriter(fh, fieldnames=["cost"]) + + with (output_dir / "system_cost.csv").open("w", newline="") as handle: + writer = csv.DictWriter(handle, fieldnames=["cost"]) writer.writeheader() writer.writerow({"cost": "42"}) - # Also put a subdir CSV for subdirectory-access test - sub = output_dir / "postprocess_snapshot" - sub.mkdir() - sub_csv = sub / "metadata.csv" - with sub_csv.open("w", newline="") as fh: - writer = csv.DictWriter(fh, fieldnames=["key", "value"]) + + subdirectory = output_dir / "postprocess_snapshot" + subdirectory.mkdir() + with (subdirectory / "metadata.csv").open("w", newline="") as handle: + writer = csv.DictWriter(handle, fieldnames=["key", "value"]) writer.writeheader() writer.writerow({"key": "version", "value": "1"}) - return tmp + return root / "ModelCases" -class TestPathTraversal(unittest.TestCase): +class OutputPathContainmentTests(unittest.TestCase): def setUp(self) -> None: - self.tmpdir_obj = tempfile.TemporaryDirectory() - tmp = Path(self.tmpdir_obj.name) - _create_case_tree(tmp) - self.env_patch = mock.patch.dict( + self.temporary_directory = tempfile.TemporaryDirectory() + root = Path(self.temporary_directory.name) + modelcases = _create_case_tree(root) + self.environment = mock.patch.dict( "os.environ", - {"HOPE_REPO_ROOT": str(tmp)}, + { + "HOPE_REPO_ROOT": str(root), + "HOPE_MODELCASES_PATH": str(modelcases), + }, clear=False, ) - self.env_patch.start() + self.environment.start() def tearDown(self) -> None: - self.env_patch.stop() - self.tmpdir_obj.cleanup() - - # ---- Legitimate reads should still work ---- + self.environment.stop() + self.temporary_directory.cleanup() def test_normal_filename_succeeds(self) -> None: result = hope_read_output(case_id="test_case", filename="system_cost.csv") self.assertTrue(result["ok"], result) - def test_subdirectory_filename_succeeds(self) -> None: + def test_contained_subdirectory_succeeds(self) -> None: result = hope_read_output( case_id="test_case", filename="postprocess_snapshot/metadata.csv", ) self.assertTrue(result["ok"], result) - # ---- Path traversal attempts MUST be blocked ---- - - def test_dot_dot_traversal_is_blocked(self) -> None: + def test_parent_traversal_is_blocked(self) -> None: result = hope_read_output( case_id="test_case", filename="../../etc/passwd", @@ -80,7 +78,7 @@ def test_dot_dot_traversal_is_blocked(self) -> None: self.assertFalse(result["ok"]) self.assertEqual(result["error_type"], "path_traversal") - def test_dot_dot_within_subdir_is_blocked(self) -> None: + def test_normalized_parent_traversal_is_blocked(self) -> None: result = hope_read_output( case_id="test_case", filename="postprocess_snapshot/../../Settings/HOPE_model_settings.yml", @@ -89,24 +87,10 @@ def test_dot_dot_within_subdir_is_blocked(self) -> None: self.assertEqual(result["error_type"], "path_traversal") def test_absolute_path_is_blocked(self) -> None: - result = hope_read_output( - case_id="test_case", - filename="/etc/passwd", - ) + result = hope_read_output(case_id="test_case", filename="/etc/passwd") self.assertFalse(result["ok"]) self.assertEqual(result["error_type"], "path_traversal") - def test_encoded_dot_dot_not_normalised_away(self) -> None: - # Depending on OS normalisation this might just be file-not-found, - # but must never succeed as a file read outside the output dir. - result = hope_read_output( - case_id="test_case", - filename="..%2F..%2Fetc%2Fpasswd", - ) - # Acceptable outcomes: path_traversal error OR file_not_found — - # NOT a successful read. - self.assertFalse(result["ok"]) - if __name__ == "__main__": unittest.main() From 15cc27583039c3d3b4ce65c0f0adba144c490e6b Mon Sep 17 00:00:00 2001 From: swang22 Date: Thu, 22 Oct 2026 20:26:02 -0400 Subject: [PATCH 2/2] Address holistic workflow review Restore PowerMCP path configuration through a downstream-owned adapter. Sync process-aware persistent status handling, compatibility and parsing preflights, canonical case IDs, and fast state-machine regression tests from HOPE.jl 6c69565. --- HOPE/README.md | 17 +- HOPE/SYNCED_FROM_HOPE.yml | 2 +- HOPE/src/hope_mcp_server/core.py | 244 ++++++++++++++++-- HOPE/src/hope_mcp_server/powermcp_adapter.py | 38 +++ HOPE/src/hope_mcp_server/server.py | 6 + HOPE/tests/test_holistic_agent_integration.py | 6 +- ...test_holistic_agent_md_july_integration.py | 8 +- HOPE/tests/test_holistic_state_machine.py | 235 +++++++++++++++++ HOPE/tests/test_powermcp_adapter.py | 79 ++++++ README.md | 2 +- powermcp/README.md | 2 + 11 files changed, 603 insertions(+), 36 deletions(-) create mode 100644 HOPE/src/hope_mcp_server/powermcp_adapter.py create mode 100644 HOPE/tests/test_holistic_state_machine.py create mode 100644 HOPE/tests/test_powermcp_adapter.py diff --git a/HOPE/README.md b/HOPE/README.md index e16b88d9..e6b66f1c 100644 --- a/HOPE/README.md +++ b/HOPE/README.md @@ -151,6 +151,14 @@ Existing outputs are excluded. Repeating the same request ID with identical inputs returns the existing run, while conflicting reuse is rejected. Poll `hope_holistic_status(request_id)` for reconnect-safe state and artifact paths. +The configured HOPE checkout must contain +`tools/repo_utils/run_holistic_agent_job.jl`, first shipped in HOPE.jl revision +`8126f0f`. The server checks this before copying either source case. Persistent +manifests record the launcher and Julia process IDs, so interrupted work becomes +a structured `failed` result rather than remaining `running` forever. Public +terminal status values remain the same before and after a reconnect: `done`, +`failed`, or `cancelled`. + Set `HOPE_AGENT_RUNS_ROOT` to move the persistent workspaces from their default `/artifacts/agent_runs` location. Set `HOPE_MODELCASES_PATH` when ModelCases is maintained as a separate checkout. @@ -159,9 +167,12 @@ Set `HOPE_AGENT_RUNS_ROOT` to move the persistent workspaces from their default The model-aware `core.py` and opt-in integration tests are synchronized from `HOPE-Model-Project/HOPE.jl/tools/hope_mcp_server`. PowerMCP retains ownership -of its MCP SDK 2 transport entrypoints, packaging, lockfile, documentation, and -layout-specific unit tests. `SYNCED_FROM_HOPE.yml` records the exact upstream -HOPE commit represented by this downstream copy. +of its MCP SDK 2 transport entrypoints, configuration adapter, packaging, +lockfile, documentation, and layout-specific unit tests. The adapter translates +`powermcp config` values for `hope.repo_root`, `hope.julia_bin`, and +`hope.julia_depot_path` into the standalone server's environment contract only +when the corresponding environment variable is unset. `SYNCED_FROM_HOPE.yml` +records the exact upstream HOPE commit represented by this downstream copy. ## Local run diff --git a/HOPE/SYNCED_FROM_HOPE.yml b/HOPE/SYNCED_FROM_HOPE.yml index fa632f5e..fb0a7290 100644 --- a/HOPE/SYNCED_FROM_HOPE.yml +++ b/HOPE/SYNCED_FROM_HOPE.yml @@ -1,5 +1,5 @@ schema_version: 1 upstream_repository: https://github.com/HOPE-Model-Project/HOPE.jl upstream_path: tools/hope_mcp_server -upstream_commit: 8126f0f6b83515f818e4c8f73b5e10e9286da68a +upstream_commit: 6c6956517a53ae01f4c2d3db562fc3e1fe30bc65 sync_manifest: tools/hope_mcp_server/powermcp_sync.yml diff --git a/HOPE/src/hope_mcp_server/core.py b/HOPE/src/hope_mcp_server/core.py index 49854c67..e836c16c 100644 --- a/HOPE/src/hope_mcp_server/core.py +++ b/HOPE/src/hope_mcp_server/core.py @@ -30,6 +30,8 @@ } HOLISTIC_REQUEST_SCHEMA_VERSION = 1 +HOLISTIC_RUNNER_RELATIVE_PATH = Path("tools") / "repo_utils" / "run_holistic_agent_job.jl" +HOLISTIC_MINIMUM_HOPE_REVISION = "8126f0f" HOLISTIC_OUTPUT_DIR_NAMES = { "output", "output_holistic", @@ -322,6 +324,109 @@ def _read_holistic_manifest(path: Path) -> dict[str, Any]: raise ValueError(f"Invalid holistic run manifest {path}: {exc}") from exc +def _process_is_alive(pid: Any) -> bool: + """Return whether *pid* identifies a live process without signalling it.""" + try: + process_id = int(pid) + except (TypeError, ValueError): + return False + if process_id <= 0: + return False + + if os.name == "nt": + # os.kill(pid, 0) is not a safe liveness probe on Windows because Python + # implements os.kill with TerminateProcess there. Query the process + # handle instead and never request termination rights. + import ctypes + from ctypes import wintypes + + process_query_limited_information = 0x1000 + still_active = 259 + kernel32 = ctypes.windll.kernel32 # type: ignore[attr-defined] + kernel32.OpenProcess.argtypes = [wintypes.DWORD, wintypes.BOOL, wintypes.DWORD] + kernel32.OpenProcess.restype = wintypes.HANDLE + kernel32.GetExitCodeProcess.argtypes = [wintypes.HANDLE, ctypes.POINTER(wintypes.DWORD)] + kernel32.GetExitCodeProcess.restype = wintypes.BOOL + kernel32.CloseHandle.argtypes = [wintypes.HANDLE] + kernel32.CloseHandle.restype = wintypes.BOOL + handle = kernel32.OpenProcess( + process_query_limited_information, + False, + process_id, + ) + if not handle: + return False + try: + exit_code = wintypes.DWORD() + if not kernel32.GetExitCodeProcess(handle, ctypes.byref(exit_code)): + return False + return exit_code.value == still_active + finally: + kernel32.CloseHandle(handle) + + try: + os.kill(process_id, 0) + except ProcessLookupError: + return False + except PermissionError: + return True + except OSError: + return False + return True + + +def _failed_holistic_manifest( + manifest_path: Path, + manifest: dict[str, Any], + *, + reason: str, +) -> dict[str, Any]: + error = { + "type": "orphaned_holistic_process", + "message": reason, + } + manifest.update( + status="FAILED", + updated_at_unix=time.time(), + error=error, + ) + _write_yaml_atomic(manifest_path, manifest) + return manifest + + +def _reconcile_holistic_manifest_process( + manifest_path: Path, + manifest: dict[str, Any], +) -> dict[str, Any]: + """Turn orphaned nonterminal manifests into durable failures.""" + status = str(manifest.get("status", "")).strip().upper() + if status == "RUNNING": + process_pid = manifest.get("process_pid") + if _process_is_alive(process_pid): + return manifest + return _failed_holistic_manifest( + manifest_path, + manifest, + reason=( + "The holistic Julia process is no longer running and did not publish " + "a terminal result. The run may have been interrupted, killed, or exhausted memory." + ), + ) + if status in {"CREATED", "VALIDATED"}: + launcher_pid = manifest.get("launcher_pid") + if _process_is_alive(launcher_pid): + return manifest + return _failed_holistic_manifest( + manifest_path, + manifest, + reason=( + "The MCP process that was preparing this holistic run is no longer active, " + "so the request never reached a recoverable running state." + ), + ) + return manifest + + def _update_holistic_manifest(path: Path, **changes: Any) -> dict[str, Any]: manifest = _read_holistic_manifest(path) manifest.update(changes) @@ -539,7 +644,7 @@ def build_holistic_run_command( ) -> list[str]: return [ *build_project_julia_command(repo_root, julia_bin), - str(repo_root / "tools" / "repo_utils" / "run_holistic_agent_job.jl"), + str(repo_root / HOLISTIC_RUNNER_RELATIVE_PATH), str(gtep_case_path), str(pcm_case_path), str(manifest_path), @@ -964,7 +1069,7 @@ def hope_warmup() -> dict[str, Any]: ) -def hope_job_status(job_id: str) -> dict[str, Any]: +def _hope_job_status_unchecked(job_id: str) -> dict[str, Any]: """Poll the status of a background job launched by hope_warmup or hope_run_hope.""" job = _jobs.get(job_id) if job is None: @@ -1105,6 +1210,25 @@ def hope_job_status(job_id: str) -> dict[str, Any]: return result +def hope_job_status(job_id: str) -> dict[str, Any]: + """Poll a background job and convert corrupt persistent state into an MCP error.""" + try: + return _hope_job_status_unchecked(job_id) + except (OSError, ValueError, yaml.YAMLError) as exc: + job = _jobs.get(job_id) + if job is None or not job.manifest_path: + raise + return error_result( + "invalid_holistic_manifest", + str(exc), + job_id=job_id, + request_id=job.request_id, + status="failed", + workspace_path=job.workspace_path, + manifest_path=job.manifest_path, + ) + + def hope_cancel_job(job_id: str, timeout_seconds: float = 5.0) -> dict[str, Any]: """Cancel a background Julia job launched by this MCP session.""" job = _jobs.get(job_id) @@ -1935,20 +2059,40 @@ def _holistic_copy_ignore(_directory: str, names: list[str]) -> set[str]: def _holistic_manifest_response(manifest: dict[str, Any], *, reused: bool) -> dict[str, Any]: - return success_result( - request_id=manifest.get("request_id"), - job_id=manifest.get("job_id"), - status=str(manifest.get("status", "unknown")).lower(), - reused=reused, - workspace_path=manifest.get("workspace_path"), - manifest_path=manifest.get("manifest_path"), - gtep_case_id=manifest.get("gtep_case_id"), - pcm_case_id=manifest.get("pcm_case_id"), - solver=manifest.get("solver"), - request_sha256=manifest.get("request_sha256"), - result=manifest.get("result"), - error=manifest.get("error"), - ) + manifest_status = str(manifest.get("status", "unknown")).strip().upper() + public_status = { + "CREATED": "preparing", + "VALIDATED": "preparing", + "RUNNING": "running", + "PUBLISHED": "done", + "FAILED": "failed", + "CANCELLED": "cancelled", + }.get(manifest_status, manifest_status.lower()) + payload = { + "request_id": manifest.get("request_id"), + "job_id": manifest.get("job_id"), + "status": public_status, + "manifest_status": manifest_status, + "reused": reused, + "workspace_path": manifest.get("workspace_path"), + "manifest_path": manifest.get("manifest_path"), + "gtep_case_id": manifest.get("gtep_case_id"), + "pcm_case_id": manifest.get("pcm_case_id"), + "solver": manifest.get("solver"), + "request_sha256": manifest.get("request_sha256"), + "result": manifest.get("result"), + "error": manifest.get("error"), + "process_pid": manifest.get("process_pid"), + } + if manifest_status == "FAILED": + error = manifest.get("error") + message = ( + str(error.get("message")) + if isinstance(error, dict) and error.get("message") + else "The holistic GTEP-to-PCM job failed." + ) + return error_result("job_failed", message, **payload) + return success_result(**payload) def _existing_holistic_request_response( @@ -1976,6 +2120,7 @@ def _existing_holistic_request_response( request_id=request_id, manifest_path=str(manifest_path), ) + existing = _reconcile_holistic_manifest_process(manifest_path, existing) if existing.get("request_sha256") != request_sha256: return error_result( "request_id_conflict", @@ -2037,8 +2182,27 @@ def hope_holistic_status(request_id: str) -> dict[str, Any]: job_id = _holistic_request_jobs.get(normalized) if job_id and job_id in _jobs: - result = hope_job_status(job_id) - result["request_id"] = normalized + runtime_result = hope_job_status(job_id) + job = _jobs[job_id] + manifest_path = Path(job.manifest_path) + if not manifest_path.is_file(): + runtime_result["request_id"] = normalized + return runtime_result + try: + manifest = _read_holistic_manifest(manifest_path) + except ValueError as exc: + return error_result( + "invalid_holistic_manifest", + str(exc), + request_id=normalized, + job_id=job_id, + status="failed", + manifest_path=str(manifest_path), + ) + result = _holistic_manifest_response(manifest, reused=False) + for key in ("elapsed_seconds", "stdout_tail", "stderr", "exit_code"): + if key in runtime_result: + result[key] = runtime_result[key] return result manifest_path = holistic_runs_root(get_repo_root()) / normalized / "run_manifest.yml" @@ -2057,6 +2221,7 @@ def hope_holistic_status(request_id: str) -> dict[str, Any]: request_id=normalized, manifest_path=str(manifest_path), ) + manifest = _reconcile_holistic_manifest_process(manifest_path, manifest) return _holistic_manifest_response(manifest, reused=True) @@ -2076,8 +2241,16 @@ def hope_run_holistic( if err is not None: return error_result("invalid_pcm_case", err.get("message", ""), case_id=pcm_case_id) - gtep_settings = read_yaml(settings_path_for_case(gtep_path)) - pcm_settings = read_yaml(settings_path_for_case(pcm_path)) + try: + gtep_settings = read_yaml(settings_path_for_case(gtep_path)) + pcm_settings = read_yaml(settings_path_for_case(pcm_path)) + except (OSError, ValueError, yaml.YAMLError) as exc: + return error_result( + "invalid_holistic_inputs", + str(exc), + gtep_case_id=gtep_case_id, + pcm_case_id=pcm_case_id, + ) gtep_mode = str(gtep_settings.get("model_mode", "")).strip().upper() pcm_mode = str(pcm_settings.get("model_mode", "")).strip().upper() if gtep_mode != "GTEP" or pcm_mode != "PCM": @@ -2104,6 +2277,19 @@ def hope_run_holistic( return request_error assert normalized_request_id is not None + holistic_runner = repo_root / HOLISTIC_RUNNER_RELATIVE_PATH + if not holistic_runner.is_file(): + return error_result( + "incompatible_hope_checkout", + ( + "This HOPE checkout does not contain the agent-safe holistic runner required " + "by hope_run_holistic. Update HOPE.jl before launching the workflow." + ), + required_path=str(holistic_runner), + minimum_hope_revision=HOLISTIC_MINIMUM_HOPE_REVISION, + repo_root=str(repo_root), + ) + julia_bin, julia_error = validate_julia_command(repo_root) if julia_error is not None: return julia_error @@ -2121,8 +2307,10 @@ def hope_run_holistic( request_payload = { "schema_version": HOLISTIC_REQUEST_SCHEMA_VERSION, "operation": "GTEP-PCM", - "gtep_case_id": gtep_case_id, - "pcm_case_id": pcm_case_id, + # Use resolved directory names so `X` and `ModelCases/X` describe the + # same request and remain idempotent across clients. + "gtep_case_id": gtep_path.name, + "pcm_case_id": pcm_path.name, "solver": selected_solver, "gtep_source_sha256": gtep_fingerprint["sha256"], "pcm_source_sha256": pcm_fingerprint["sha256"], @@ -2195,6 +2383,7 @@ def hope_run_holistic( "status": "CREATED", "created_at_unix": time.time(), "updated_at_unix": time.time(), + "launcher_pid": os.getpid(), "workspace_path": str(workspace_path), "manifest_path": str(manifest_path), "gtep_source_path": str(gtep_path), @@ -2268,11 +2457,18 @@ def hope_run_holistic( ) _holistic_request_jobs[normalized_request_id] = job_id + launched_job = _jobs.get(job_id) + process_pid = ( + getattr(launched_job.process, "pid", None) + if launched_job is not None + else None + ) _update_holistic_manifest( manifest_path, status="RUNNING", updated_at_unix=time.time(), job_id=job_id, + process_pid=process_pid, command=command, ) @@ -2281,8 +2477,8 @@ def hope_run_holistic( request_id=normalized_request_id, status="running", reused=False, - gtep_case_id=gtep_case_id, - pcm_case_id=pcm_case_id, + gtep_case_id=gtep_path.name, + pcm_case_id=pcm_path.name, solver=selected_solver, request_sha256=request_sha256, workspace_path=str(workspace_path), diff --git a/HOPE/src/hope_mcp_server/powermcp_adapter.py b/HOPE/src/hope_mcp_server/powermcp_adapter.py new file mode 100644 index 00000000..6476bc05 --- /dev/null +++ b/HOPE/src/hope_mcp_server/powermcp_adapter.py @@ -0,0 +1,38 @@ +from __future__ import annotations + +import os +from typing import Callable + + +_POWERMCP_HOPE_PATHS = { + "HOPE_REPO_ROOT": "repo_root", + "HOPE_JULIA_BIN": "julia_bin", + "JULIA_DEPOT_PATH": "julia_depot_path", +} + + +def apply_powermcp_hope_config() -> dict[str, str]: + """Expose PowerMCP's HOPE paths through the standalone server contract. + + Explicit runtime environment variables always win. Import and lookup + failures are intentionally ignored so the vendored HOPE server remains + runnable directly without importing the PowerMCP distribution. + """ + try: + from powermcp.config import get_path + except Exception: + return {} + + applied: dict[str, str] = {} + lookup: Callable[..., str] = get_path + for env_var, config_key in _POWERMCP_HOPE_PATHS.items(): + if os.environ.get(env_var): + continue + try: + value = lookup("hope", config_key, must_exist=False) + except Exception: + continue + if value: + os.environ[env_var] = str(value) + applied[env_var] = str(value) + return applied diff --git a/HOPE/src/hope_mcp_server/server.py b/HOPE/src/hope_mcp_server/server.py index 37353645..eb4900d9 100644 --- a/HOPE/src/hope_mcp_server/server.py +++ b/HOPE/src/hope_mcp_server/server.py @@ -6,6 +6,12 @@ from mcp.server.mcpserver import MCPServer as FastMCP from mcp.server.transport_security import TransportSecuritySettings +from .powermcp_adapter import apply_powermcp_hope_config + +# PowerMCP owns this adapter layer. Translate its captured installation paths +# into the environment-based contract used by the synchronized standalone core. +apply_powermcp_hope_config() + from .core import ( hope_aggregation_audit, hope_cancel_job, diff --git a/HOPE/tests/test_holistic_agent_integration.py b/HOPE/tests/test_holistic_agent_integration.py index 754a766e..a123c3b9 100644 --- a/HOPE/tests/test_holistic_agent_integration.py +++ b/HOPE/tests/test_holistic_agent_integration.py @@ -62,7 +62,7 @@ def test_restricted_gtep_pcm_request_survives_reconnect(self) -> None: while True: status = hope_holistic_status(request_id) state = status.get("status") - if state in ("done", "published", "failed", "cancelled"): + if state in ("done", "failed", "cancelled"): break self.assertTrue(status["ok"], status) if time.monotonic() >= deadline: @@ -70,7 +70,7 @@ def test_restricted_gtep_pcm_request_survives_reconnect(self) -> None: time.sleep(2.0) self.assertTrue(status["ok"], status) - self.assertIn(state, ("done", "published")) + self.assertEqual(state, "done") result = status["result"] self.assertTrue(result["handoff_valid"]) self.assertTrue(result["transfer_valid"]) @@ -93,7 +93,7 @@ def test_restricted_gtep_pcm_request_survives_reconnect(self) -> None: stream.close() recovered = hope_holistic_status(request_id) self.assertTrue(recovered["ok"], recovered) - self.assertEqual(recovered["status"], "published") + self.assertEqual(recovered["status"], "done") self.assertTrue(recovered["result"]["coverage_all_passed"]) diff --git a/HOPE/tests/test_holistic_agent_md_july_integration.py b/HOPE/tests/test_holistic_agent_md_july_integration.py index bdab31bc..c89ae4b3 100644 --- a/HOPE/tests/test_holistic_agent_md_july_integration.py +++ b/HOPE/tests/test_holistic_agent_md_july_integration.py @@ -132,7 +132,7 @@ def test_annual_gtep_july_pcm_request_is_agent_safe(self) -> None: while True: status = hope_holistic_status(request_id) state = status.get("status") - if state in ("done", "published", "failed", "cancelled"): + if state in ("done", "failed", "cancelled"): break self.assertTrue(status["ok"], status) now = time.monotonic() @@ -148,7 +148,7 @@ def test_annual_gtep_july_pcm_request_is_agent_safe(self) -> None: time.sleep(5.0) self.assertTrue(status["ok"], status) - self.assertIn(state, ("done", "published"), status) + self.assertEqual(state, "done", status) result = status["result"] self.assertTrue(result["handoff_valid"]) self.assertTrue(result["transfer_valid"]) @@ -184,7 +184,7 @@ def test_annual_gtep_july_pcm_request_is_agent_safe(self) -> None: _holistic_request_jobs.pop(request_id, None) recovered = hope_holistic_status(request_id) self.assertTrue(recovered["ok"], recovered) - self.assertEqual(recovered["status"], "published") + self.assertEqual(recovered["status"], "done") self.assertTrue(recovered["result"]["coverage_all_passed"]) repeated = hope_run_holistic( @@ -195,7 +195,7 @@ def test_annual_gtep_july_pcm_request_is_agent_safe(self) -> None: ) self.assertTrue(repeated["ok"], repeated) self.assertTrue(repeated["reused"]) - self.assertEqual(repeated["status"], "published") + self.assertEqual(repeated["status"], "done") self.assertEqual(len(list(runs_root.iterdir())), 1) finally: _holistic_request_jobs.pop(request_id, None) diff --git a/HOPE/tests/test_holistic_state_machine.py b/HOPE/tests/test_holistic_state_machine.py new file mode 100644 index 00000000..4eedafd1 --- /dev/null +++ b/HOPE/tests/test_holistic_state_machine.py @@ -0,0 +1,235 @@ +from __future__ import annotations + +import os +import sys +import tempfile +import unittest +from pathlib import Path +from unittest import mock + +import yaml + +PACKAGE_ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(PACKAGE_ROOT / "src")) + +from hope_mcp_server.core import ( # noqa: E402 + _holistic_request_jobs, + _Job, + _jobs, + hope_holistic_status, + hope_run_holistic, +) + + +class HolisticStateMachineTests(unittest.TestCase): + def setUp(self) -> None: + self.temporary = tempfile.TemporaryDirectory() + self.root = Path(self.temporary.name) + self.runs = self.root / "agent-runs" + runner = self.root / "tools" / "repo_utils" / "run_holistic_agent_job.jl" + runner.parent.mkdir(parents=True) + runner.write_text("# test runner\n", encoding="utf-8") + self.runner = runner + self.gtep = self._write_case("gtep_case", "GTEP") + self.pcm = self._write_case("pcm_case", "PCM") + self.env_patch = mock.patch.dict( + "os.environ", + { + "HOPE_REPO_ROOT": str(self.root), + "HOPE_MODELCASES_PATH": str(self.root / "ModelCases"), + "HOPE_AGENT_RUNS_ROOT": str(self.runs), + "HOPE_JULIA_BIN": "julia-test", + }, + clear=False, + ) + self.env_patch.start() + + def tearDown(self) -> None: + self.env_patch.stop() + for request_id in list(_holistic_request_jobs): + if request_id.startswith("state-"): + _holistic_request_jobs.pop(request_id, None) + for job_id, job in list(_jobs.items()): + if job.request_id.startswith("state-"): + _jobs.pop(job_id, None) + self.temporary.cleanup() + + def _write_case(self, name: str, mode: str) -> Path: + case = self.root / "ModelCases" / name + (case / "Settings").mkdir(parents=True) + (case / "Data").mkdir() + (case / "Settings" / "HOPE_model_settings.yml").write_text( + f"model_mode: {mode}\nsolver: highs\nDataCase: Data/\n", + encoding="utf-8", + ) + (case / "Data" / "input.csv").write_text("name,value\ntest,1\n", encoding="utf-8") + return case + + def _fake_launch(self, command: list[str], repo_root: Path, case_id: str = "", **kwargs: object) -> str: + process = mock.Mock() + process.pid = os.getpid() + process.poll.return_value = None + _jobs["statejob"] = _Job( + job_id="statejob", + command=command, + process=process, + case_id=case_id, + job_kind=str(kwargs.get("job_kind", "generic")), + request_id=str(kwargs.get("request_id", "")), + workspace_path=str(kwargs.get("workspace_path", "")), + manifest_path=str(kwargs.get("manifest_path", "")), + output_case_path=str(kwargs.get("output_case_path", "")), + ) + return "statejob" + + def _launch(self, request_id: str) -> dict: + with ( + mock.patch( + "hope_mcp_server.core.validate_julia_command", + return_value=("julia-test", None), + ), + mock.patch( + "hope_mcp_server.core._launch_job", + side_effect=self._fake_launch, + ), + ): + return hope_run_holistic( + "gtep_case", + "pcm_case", + request_id=request_id, + solver="highs", + ) + + def test_launch_records_process_identity_and_reconnect_reports_running(self) -> None: + launched = self._launch("state-running") + manifest = yaml.safe_load(Path(launched["manifest_path"]).read_text(encoding="utf-8")) + self.assertEqual(manifest["launcher_pid"], os.getpid()) + self.assertEqual(manifest["process_pid"], os.getpid()) + + _holistic_request_jobs.pop("state-running", None) + with mock.patch("hope_mcp_server.core._process_is_alive", return_value=True): + recovered = hope_holistic_status("state-running") + self.assertTrue(recovered["ok"]) + self.assertEqual(recovered["status"], "running") + + def test_dead_process_becomes_durable_failure(self) -> None: + self._launch("state-dead") + _holistic_request_jobs.pop("state-dead", None) + with mock.patch("hope_mcp_server.core._process_is_alive", return_value=False): + failed = hope_holistic_status("state-dead") + self.assertFalse(failed["ok"]) + self.assertEqual(failed["status"], "failed") + self.assertEqual(failed["error_type"], "job_failed") + self.assertIn("no longer running", failed["message"]) + + persisted = hope_holistic_status("state-dead") + self.assertFalse(persisted["ok"]) + self.assertEqual(persisted["status"], "failed") + + def test_dead_launcher_during_preparation_becomes_failure(self) -> None: + launched = self._launch("state-preparing") + manifest_path = Path(launched["manifest_path"]) + manifest = yaml.safe_load(manifest_path.read_text(encoding="utf-8")) + manifest["status"] = "VALIDATED" + manifest.pop("process_pid", None) + manifest_path.write_text(yaml.safe_dump(manifest, sort_keys=False), encoding="utf-8") + _holistic_request_jobs.pop("state-preparing", None) + + with mock.patch("hope_mcp_server.core._process_is_alive", return_value=False): + failed = hope_holistic_status("state-preparing") + self.assertFalse(failed["ok"]) + self.assertEqual(failed["status"], "failed") + self.assertIn("preparing", failed["message"]) + + def test_terminal_status_is_stable_across_reconnect(self) -> None: + self._launch("state-done") + job = _jobs["statejob"] + job.process.poll.return_value = 0 + job.stdout_lines = [ + "HOPE_HANDOFF_VALID=true", + "HOPE_TRANSFER_VALID=true", + ] + current = hope_holistic_status("state-done") + self.assertTrue(current["ok"]) + self.assertEqual(current["status"], "done") + + _holistic_request_jobs.pop("state-done", None) + recovered = hope_holistic_status("state-done") + self.assertTrue(recovered["ok"]) + self.assertEqual(recovered["status"], "done") + + def test_failed_status_is_stable_across_reconnect(self) -> None: + self._launch("state-failed") + job = _jobs["statejob"] + job.process.poll.return_value = 2 + job.stderr_lines = ["solver failed"] + current = hope_holistic_status("state-failed") + self.assertFalse(current["ok"]) + self.assertEqual(current["error_type"], "job_failed") + self.assertEqual(current["status"], "failed") + + _holistic_request_jobs.pop("state-failed", None) + recovered = hope_holistic_status("state-failed") + self.assertFalse(recovered["ok"]) + self.assertEqual(recovered["error_type"], "job_failed") + self.assertEqual(recovered["status"], "failed") + + def test_corrupt_active_manifest_returns_structured_error(self) -> None: + launched = self._launch("state-corrupt") + Path(launched["manifest_path"]).write_text("- not\n- a\n- mapping\n", encoding="utf-8") + _jobs["statejob"].process.poll.return_value = 0 + result = hope_holistic_status("state-corrupt") + self.assertFalse(result["ok"]) + self.assertEqual(result["error_type"], "invalid_holistic_manifest") + self.assertEqual(result["status"], "failed") + + def test_invalid_case_yaml_returns_structured_error(self) -> None: + (self.gtep / "Settings" / "HOPE_model_settings.yml").write_text( + "model_mode: [GTEP\n", + encoding="utf-8", + ) + result = hope_run_holistic( + "gtep_case", + "pcm_case", + request_id="state-invalid-yaml", + ) + self.assertFalse(result["ok"]) + self.assertEqual(result["error_type"], "invalid_holistic_inputs") + + def test_missing_runner_fails_before_workspace_creation(self) -> None: + self.runner.unlink() + with mock.patch("hope_mcp_server.core.validate_julia_command") as validate: + result = hope_run_holistic( + "gtep_case", + "pcm_case", + request_id="state-old-hope", + ) + self.assertFalse(result["ok"]) + self.assertEqual(result["error_type"], "incompatible_hope_checkout") + self.assertEqual(result["minimum_hope_revision"], "8126f0f") + self.assertFalse((self.runs / "state-old-hope").exists()) + validate.assert_not_called() + + def test_equivalent_case_identifiers_are_idempotent(self) -> None: + first = self._launch("state-canonical") + with ( + mock.patch( + "hope_mcp_server.core.validate_julia_command", + return_value=("julia-test", None), + ), + mock.patch("hope_mcp_server.core._launch_job") as launch, + ): + second = hope_run_holistic( + "ModelCases/gtep_case", + "ModelCases/pcm_case", + request_id="state-canonical", + solver="highs", + ) + self.assertTrue(first["ok"]) + self.assertTrue(second["ok"]) + self.assertTrue(second["reused"]) + launch.assert_not_called() + + +if __name__ == "__main__": + unittest.main() diff --git a/HOPE/tests/test_powermcp_adapter.py b/HOPE/tests/test_powermcp_adapter.py new file mode 100644 index 00000000..95af6e76 --- /dev/null +++ b/HOPE/tests/test_powermcp_adapter.py @@ -0,0 +1,79 @@ +from __future__ import annotations + +import os +import sys +import types +import unittest +from pathlib import Path +from unittest import mock + +PACKAGE_ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(PACKAGE_ROOT / "src")) + +from hope_mcp_server.powermcp_adapter import apply_powermcp_hope_config # noqa: E402 + + +class PowerMCPHopeAdapterTests(unittest.TestCase): + def _fake_modules(self, values: dict[str, str]) -> dict[str, types.ModuleType]: + package = types.ModuleType("powermcp") + config = types.ModuleType("powermcp.config") + + def get_path(tool: str, key: str, *, must_exist: bool = True) -> str: + self.assertEqual(tool, "hope") + self.assertFalse(must_exist) + if key not in values: + raise RuntimeError("not configured") + return values[key] + + config.get_path = get_path # type: ignore[attr-defined] + package.config = config # type: ignore[attr-defined] + return {"powermcp": package, "powermcp.config": config} + + def test_applies_configured_paths_when_runtime_environment_is_unset(self) -> None: + values = { + "repo_root": "C:/src/HOPE", + "julia_bin": "C:/Julia/bin/julia.exe", + "julia_depot_path": "D:/julia_depot", + } + with ( + mock.patch.dict(sys.modules, self._fake_modules(values)), + mock.patch.dict(os.environ, {}, clear=True), + ): + applied = apply_powermcp_hope_config() + self.assertEqual(os.environ["HOPE_REPO_ROOT"], values["repo_root"]) + self.assertEqual(os.environ["HOPE_JULIA_BIN"], values["julia_bin"]) + self.assertEqual(os.environ["JULIA_DEPOT_PATH"], values["julia_depot_path"]) + self.assertEqual(set(applied), {"HOPE_REPO_ROOT", "HOPE_JULIA_BIN", "JULIA_DEPOT_PATH"}) + + def test_explicit_runtime_environment_takes_precedence(self) -> None: + values = { + "repo_root": "C:/configured/HOPE", + "julia_bin": "C:/configured/julia.exe", + "julia_depot_path": "D:/configured/depot", + } + explicit = { + "HOPE_REPO_ROOT": "C:/explicit/HOPE", + "HOPE_JULIA_BIN": "C:/explicit/julia.exe", + "JULIA_DEPOT_PATH": "D:/explicit/depot", + } + with ( + mock.patch.dict(sys.modules, self._fake_modules(values)), + mock.patch.dict(os.environ, explicit, clear=True), + ): + applied = apply_powermcp_hope_config() + self.assertEqual(applied, {}) + for key, value in explicit.items(): + self.assertEqual(os.environ[key], value) + + def test_missing_optional_setting_does_not_break_standalone_server(self) -> None: + with ( + mock.patch.dict(sys.modules, self._fake_modules({"repo_root": "C:/src/HOPE"})), + mock.patch.dict(os.environ, {}, clear=True), + ): + applied = apply_powermcp_hope_config() + self.assertEqual(applied, {"HOPE_REPO_ROOT": "C:/src/HOPE"}) + self.assertNotIn("JULIA_DEPOT_PATH", os.environ) + + +if __name__ == "__main__": + unittest.main() diff --git a/README.md b/README.md index f3e04b92..6614542a 100644 --- a/README.md +++ b/README.md @@ -118,7 +118,7 @@ These tools wrap commercial or locally-installed software, so PowerMCP stores th | PSLF | `pslf.python_lib` | `powermcp config set pslf.python_lib "C:\Program Files\GE PSLF\PSLF_PYTHON"` | | PowerFactory | `powerfactory.python_path` | `powermcp config set powerfactory.python_path "...\DIgSILENT\PowerFactory 2024\Python\3.11"` | | LTSpice | `ltspice.exe` *(auto-detected)* | Found automatically in standard locations — usually no setup needed. Override: `powermcp config set ltspice.exe "C:\Program Files\ADI\LTspice\LTspice.exe"` | -| HOPE | `hope.repo_root`, `hope.julia_bin` | `powermcp config set hope.repo_root "C:\src\HOPE"` | +| HOPE | `hope.repo_root`, `hope.julia_bin`, `hope.julia_depot_path` | `powermcp config set hope.repo_root "C:\src\HOPE"` | | GenX | `genx.repo_root` | `powermcp config set genx.repo_root "/home/me/GenX.jl"` — the GenX.jl checkout; `GENX_DIR` overrides it | | PowerWorld | *(none)* | `esa` auto-discovers a running, licensed Simulator via COM; the `powerworld` extra also installs `numba` (required by esa) | | PSCAD | *(none)* | `pip install powermcp[pscad-windows]` provides `mhi-pscad`; PSCAD must be installed | diff --git a/powermcp/README.md b/powermcp/README.md index a4e07d19..e41de5fe 100644 --- a/powermcp/README.md +++ b/powermcp/README.md @@ -112,6 +112,8 @@ powermcp config set ltspice.exe "C:\Program Files\ADI\LTspice\LTspice.exe" powermcp config set pslf.python_lib "C:\Program Files\GE PSLF\PSLF_PYTHON" powermcp config set powerfactory.python_path "...\DIgSILENT\PowerFactory 2024\Python\3.11" powermcp config set hope.repo_root "C:\src\HOPE" +powermcp config set hope.julia_bin "C:\Users\me\.julia\juliaup\julia.exe" +powermcp config set hope.julia_depot_path "D:\julia_depot" ``` Resolution order for each key is **environment variable** (`POWERMCP_PSSE_BIN`, ...),