295 lines
9.4 KiB
Python
295 lines
9.4 KiB
Python
|
|
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()
|