aps-agent/tests/e2e/round65_closed_loop_server.py

295 lines
9.4 KiB
Python
Raw Normal View History

from __future__ import annotations
import argparse
import atexit
import copy
import hashlib
import json
import os
import shutil
import sys
import tempfile
import types
from pathlib import Path
from threading import RLock
from typing import Any
REPO_ROOT = Path(__file__).resolve().parents[2]
if str(REPO_ROOT) not in sys.path:
sys.path.insert(0, str(REPO_ROOT))
LIVE_MOM_WORLD = REPO_ROOT / "server" / "data" / "world-proj_712276ba.json"
_RUNTIME_ROOT = Path(tempfile.mkdtemp(prefix="aps-round65-e2e-"))
_RUNTIME_DATA = _RUNTIME_ROOT / "data"
_RUNTIME_DATA.mkdir(parents=True, exist_ok=True)
def _configure_isolated_environment() -> None:
paths = {
"APS_HOME": _RUNTIME_ROOT,
"APS_DATA_DIR": _RUNTIME_DATA,
"APS_DB_PATH": _RUNTIME_DATA / "master.db",
"APS_WORLD_PATH": _RUNTIME_DATA / "world.json",
"APS_PROJECTS_PATH": _RUNTIME_ROOT / "sessions" / "workspace.json",
"APS_KNOWLEDGE_PATH": _RUNTIME_DATA / "knowledge.json",
"APS_CHECKPOINT_PATH": _RUNTIME_DATA / "checkpoints.json",
"APS_PREFERENCE_PATH": _RUNTIME_DATA / "preferences.json",
"APS_EMBEDDINGS_PATH": _RUNTIME_DATA / "embeddings.json",
"APS_APPROVAL_PATH": _RUNTIME_DATA / "approvals.json",
"APS_AUDIT_LEDGER_DIR": _RUNTIME_DATA / "audit-ledger",
"APS_AUDIT_MIRROR_DIR": _RUNTIME_DATA / "audit-mirror",
"APS_AUTOMATION_STATE_PATH": _RUNTIME_DATA / "automation.json",
"APS_MCP_BUS_PATH": _RUNTIME_DATA / "mcp-bus.json",
"APS_BRANCH_PATH": _RUNTIME_DATA / "branches.json",
"APS_BRANCH_DIR": _RUNTIME_DATA / "branches",
"APS_GOLDEN_CACHE": _RUNTIME_DATA / "golden-tests.json",
}
for key, value in paths.items():
os.environ[key] = str(value)
os.environ["APS_APPROVAL_BACKEND"] = "file"
os.environ["APS_AUTOMATION_DRIVER"] = "0"
os.environ["APS_AUTH_COOKIE_SECURE"] = "0"
os.environ.pop("APS_DATABASE_URL", None)
_configure_isolated_environment()
atexit.register(lambda: shutil.rmtree(_RUNTIME_ROOT, ignore_errors=True))
from fastapi import FastAPI, HTTPException
from server.agent_core import harness
from tests.auth_provider import TestAuthProvider
from tests.golden.test_closed_loop_runtime import _ready_world
import server.auth.middleware as auth_middleware
import server.gateway.app as gateway_module
import server.state.projects as projects_module
def _sha256(path: Path) -> str:
return hashlib.sha256(path.read_bytes()).hexdigest()
_LIVE_WORLD_SHA256 = _sha256(LIVE_MOM_WORLD)
_GENERATED_LIST_KEYS = (
"closedLoopProblems",
"manufacturingDemands",
"supplyEvents",
"flexScheduleVersions",
"flexVirtualLines",
"flexWorkOrders",
"scheduleVersions",
"productionOrders",
"workOrders",
"conflicts",
"flexConflicts",
"purchaseOrders",
"outsourceOrders",
"makeSuggestions",
"mesLinks",
"mesDispatches",
"mesReports",
"mesReceipts",
"wmsReceipts",
)
def _mom_world() -> dict[str, Any]:
# Read-only source: every test receives a deep in-memory copy. save() below
# never serializes this object back to server/data/**.
world = json.loads(LIVE_MOM_WORLD.read_text(encoding="utf-8"))
world = copy.deepcopy(world)
world["businessDate"] = "2026-08-02"
for key in _GENERATED_LIST_KEYS:
world[key] = []
return world
def _positive_world() -> dict[str, Any]:
world = copy.deepcopy(_ready_world())
world["businessDate"] = "2026-08-02"
for key in _GENERATED_LIST_KEYS:
world[key] = []
for order in world.get("salesOrders") or []:
order.setdefault("customerName", "Round 65 E2E ??")
order.setdefault("customerLevel", "A")
order.setdefault("orderDate", "2026-08-02")
order.setdefault("deliveryDate", order.get("dueDate") or "2026-08-04")
return world
_FIXTURES = {"mom": _mom_world, "ready": _positive_world}
class MemoryWorldStore:
"""WorldStore-compatible process-local store; save is deliberately a no-op."""
def __init__(self) -> None:
self._lock = RLock()
self.world_key = "round65-e2e"
self.fixture_name = "mom"
self.save_count = 0
self.data: dict[str, Any] = _mom_world()
def switch(self, fixture_name: str) -> dict[str, Any]:
factory = _FIXTURES.get(fixture_name)
if factory is None:
raise KeyError(fixture_name)
with self._lock:
self.fixture_name = fixture_name
self.save_count = 0
self.data = factory()
return self.data
def next_id(self, kind: str) -> int:
with self._lock:
key = f"_round65_e2e_{kind}"
self.data[key] = int(self.data.get(key) or 900_000) + 1
return int(self.data[key])
def save(self) -> None:
# Preserve production write semantics in memory while proving no live
# world file is ever mutated by this runner.
with self._lock:
self.save_count += 1
STORE = MemoryWorldStore()
class Round65TestAuthProvider(TestAuthProvider):
async def precheck(
self,
username: str = "",
tenant_code: str = "",
tenant_name: str = "",
) -> dict[str, Any]:
return {
"requireCaptcha": False,
"username": username,
"tenantCode": tenant_code,
"tenantName": tenant_name,
}
async def captcha(self) -> dict[str, Any]:
return {"captchaId": "round65-e2e", "image": "", "expiresIn": 300}
AUTH_PROVIDER = Round65TestAuthProvider("tenant-round65-e2e")
auth_middleware.get_auth_provider = lambda: AUTH_PROVIDER
gateway_module.get_auth_provider = lambda: AUTH_PROVIDER
gateway_module.get_store = lambda: STORE
# The production middleware enforces project write membership. The E2E runner
# keeps the real ProjectStore API for page bootstrap, but makes its temporary
# tenant workspace writable so P2/P3 API calls exercise the production routes.
_ORIGINAL_GET_PROJECT_STORE = projects_module.get_project_store
def _get_writable_temp_project_store():
project_store = _ORIGINAL_GET_PROJECT_STORE()
project_store.require_active_write = types.MethodType(
lambda self: None,
project_store,
)
return project_store
projects_module.get_project_store = _get_writable_temp_project_store
# Confirmations staged against the in-memory E2E world must resolve to that
# exact world rather than looking up a persistent project world.
harness.resolve_confirmation_store = lambda _confirm_id, fallback: fallback
def _reset_approval_store() -> None:
approval_path = _RUNTIME_DATA / "approvals.json"
approval_path.unlink(missing_ok=True)
harness.configure_approval_store(path=str(approval_path))
_reset_approval_store()
production_app: FastAPI = gateway_module.create_app()
app = FastAPI(title="Round 65 isolated closed-loop E2E")
@app.get("/__e2e__/health")
async def e2e_health() -> dict[str, Any]:
return {
"ok": True,
"fixture": STORE.fixture_name,
"runtimeRoot": str(_RUNTIME_ROOT),
"liveWorldSha256": _LIVE_WORLD_SHA256,
"liveWorldSha256Now": _sha256(LIVE_MOM_WORLD),
}
@app.post("/__e2e__/fixture/{fixture_name}")
async def e2e_switch_fixture(fixture_name: str) -> dict[str, Any]:
try:
world = STORE.switch(fixture_name)
except KeyError as exc:
raise HTTPException(status_code=404, detail=f"unknown fixture: {fixture_name}") from exc
_reset_approval_store()
return {
"ok": True,
"fixture": fixture_name,
"businessDate": world.get("businessDate"),
"salesOrderCount": len(world.get("salesOrders") or []),
"liveWorldSha256": _LIVE_WORLD_SHA256,
"liveWorldSha256Now": _sha256(LIVE_MOM_WORLD),
}
@app.get("/__e2e__/state")
async def e2e_state() -> dict[str, Any]:
world = STORE.data
versions = world.get("flexScheduleVersions") or []
latest = versions[-1] if versions else None
return {
"fixture": STORE.fixture_name,
"saveCount": STORE.save_count,
"closedLoopProblemCount": len(world.get("closedLoopProblems") or []),
"manufacturingDemandCount": len(world.get("manufacturingDemands") or []),
"purchaseSuggestionCount": len(world.get("purchaseOrders") or []),
"virtualLineCount": len(world.get("flexVirtualLines") or []),
"workOrderCount": len(world.get("flexWorkOrders") or []),
"latestVersion": latest,
"mesLinkCount": len(world.get("mesLinks") or []),
"liveWorldSha256": _LIVE_WORLD_SHA256,
"liveWorldSha256Now": _sha256(LIVE_MOM_WORLD),
}
# Keep test-only endpoints ahead of the production SPA catch-all route.
app.mount("/", production_app)
def main() -> None:
parser = argparse.ArgumentParser(description="Round 65 isolated closed-loop E2E backend")
parser.add_argument("--host", default="127.0.0.1")
parser.add_argument("--port", type=int, default=int(os.environ.get("ROUND65_E2E_PORT", "8115")))
args = parser.parse_args()
import uvicorn
print(
json.dumps(
{
"event": "round65-e2e-server-start",
"host": args.host,
"port": args.port,
"runtimeRoot": str(_RUNTIME_ROOT),
"liveWorldSha256": _LIVE_WORLD_SHA256,
},
ensure_ascii=False,
),
flush=True,
)
uvicorn.run(app, host=args.host, port=args.port, log_level="info")
if __name__ == "__main__":
main()