File size: 8,823 Bytes
92d87c0
 
 
 
 
 
 
 
 
 
b96f305
 
 
 
 
 
92d87c0
 
 
 
 
 
 
 
 
8be219d
b96f305
 
92d87c0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8be219d
92d87c0
 
 
 
 
 
 
 
 
 
 
 
 
b96f305
92d87c0
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
"""FastAPI server for the Space Manufacturing RL OpenEnv environment."""
from __future__ import annotations

import asyncio
import os
import sys
import uvicorn
from pathlib import Path
from typing import Any, Dict, Optional

# Make 'SpaceFactory' importable regardless of working directory.
# server/app.py is at <root>/SpaceFactory/server/app.py β†’ parentΓ—3 = <root>
_pkg_parent = str(Path(__file__).resolve().parent.parent.parent)
if _pkg_parent not in sys.path:
    sys.path.insert(0, _pkg_parent)

try:
    from fastapi import FastAPI, HTTPException, Query
    from fastapi.middleware.cors import CORSMiddleware
    from fastapi.responses import FileResponse, HTMLResponse
    from fastapi.staticfiles import StaticFiles
    from pydantic import BaseModel
except ImportError as exc:
    raise SystemExit(f"FastAPI is required: pip install fastapi uvicorn\n{exc}") from exc

from .ui_demo import ManufacturingUIDemo
from SpaceFactory.env import ManufacturingTaskEnv
from SpaceFactory.models import ManufacturingAction, ManufacturingObservation

# ── app ────────────────────────────────────────────────────────────────────────
app = FastAPI(
    title="Space Manufacturing RL",
    description="Custom space manufacturing frontend with mounted OpenEnv API routes.",
    version="0.1.0",
)

app.add_middleware(
    CORSMiddleware,
    allow_origins=["http://localhost:4173", "http://127.0.0.1:4173", "http://localhost:5173", "http://127.0.0.1:5173"],
    allow_methods=["*"],
    allow_headers=["*"],
)


@app.on_event("startup")
async def _suppress_win32_connection_reset() -> None:
    """Suppress spurious WinError 10054 from asyncio ProactorEventLoop on Windows."""
    if sys.platform != "win32":
        return
    loop = asyncio.get_event_loop()

    def _handler(loop: asyncio.AbstractEventLoop, context: dict) -> None:
        exc = context.get("exception")
        if isinstance(exc, ConnectionResetError):
            return  # benign Windows ProactorEventLoop artifact β€” silently ignore
        loop.default_exception_handler(context)

    loop.set_exception_handler(_handler)

# Single shared environment instance (stateful per process)
_env = ManufacturingTaskEnv(task_name="easy")

# UI demo instance
_ui_demo = ManufacturingUIDemo()

# Mount built frontend static assets if dist/ exists
_here   = Path(__file__).resolve().parent
_dist   = _here.parent / "frontend" / "dist"
_assets = _dist / "assets"
if _assets.exists():
    app.mount("/assets",     StaticFiles(directory=str(_assets)), name="assets")
    app.mount("/web/assets", StaticFiles(directory=str(_assets)), name="web-assets")


# ── helpers ────────────────────────────────────────────────────────────────────

class StepRequest(BaseModel):
    """Wrapper for a single environment step action."""

    action: Dict[str, Any]
    timeout_s: Optional[float] = 30.0


# ── OpenEnv core endpoints ─────────────────────────────────────────────────────

@app.post("/reset")
def reset(task: Optional[str] = Query(default=None)):
    """Reset the episode, optionally switching to a different task."""
    if task is not None:
        try:
            _env.set_task(task)
        except ValueError as exc:
            raise HTTPException(status_code=400, detail=str(exc)) from exc
    return _env.reset().model_dump()


@app.post("/step")
def step(body: StepRequest):
    """Advance the environment by one step."""
    try:
        action = ManufacturingAction.model_validate(body.action)
    except Exception as exc:
        raise HTTPException(status_code=422, detail=f"Invalid action: {exc}") from exc
    obs, reward, done, info = _env.step(action)
    return {"observation": obs.model_dump(), "reward": reward.model_dump(),
            "done": done, "info": info}


@app.get("/state")
def state():
    """Return the full current environment state snapshot."""
    return _env.state().model_dump()


@app.get("/tasks")
def list_tasks():
    """List all available task presets."""
    return ManufacturingTaskEnv.list_tasks()


@app.get("/schema")
def schema():
    """Return action, observation, and state JSON schemas."""
    return {
        "action_space":      ManufacturingAction.model_json_schema(),
        "observation_space": ManufacturingObservation.model_json_schema(),
        "state":             _env.state().model_json_schema(),
    }


@app.get("/health")
def health():
    """Health probe used by OpenEnv validators."""
    return {"status": "healthy"}


@app.get("/metadata")
def metadata():
    """Environment metadata required by the OpenEnv runtime standard."""
    return {
        "name": "space_manufacturing",
        "description": (
            "Orbital manufacturing platform coordination environment "
            "for production and delivery scheduling."
        ),
        "version": "0.1.0",
        "tasks": list(ManufacturingTaskEnv.list_tasks().keys()),
    }


# ── UI demo endpoints ──────────────────────────────────────────────────────────

@app.get("/api/ui/demo")
def ui_demo_snapshot():
    """Return the current UI demo snapshot (used by the React frontend)."""
    return _ui_demo.snapshot()


@app.post("/api/ui/demo/reset")
def ui_demo_reset(task_name: str = "medium"):
    """Reset the UI demo to a specific task."""
    if task_name not in ManufacturingTaskEnv.list_tasks():
        raise HTTPException(status_code=400, detail=f"Unknown task: {task_name}")
    return _ui_demo.reset(task_name)


@app.post("/api/ui/demo/step")
def ui_demo_step():
    """Advance the UI demo by one heuristic step."""
    return _ui_demo.step()


# ── Frontend SPA routes ────────────────────────────────────────────────────────

@app.get("/web", response_class=FileResponse, include_in_schema=False)
def serve_web_index():
    """Serve the React SPA index page."""
    index = _dist / "index.html"
    if index.exists():
        return FileResponse(str(index))
    return HTMLResponse(_fallback_html())


@app.get("/web/{full_path:path}", include_in_schema=False)
def serve_web_app(full_path: str):
    """Serve built SPA assets, falling back to index.html for client-side routing."""
    candidate = _dist / full_path
    if candidate.exists() and candidate.is_file():
        return FileResponse(str(candidate))
    index = _dist / "index.html"
    if index.exists():
        return FileResponse(str(index))
    return HTMLResponse(_fallback_html())


def _fallback_html() -> str:
    """Minimal status page shown when the frontend dist/ hasn't been built yet."""
    s = _env.state()
    platforms_html = "".join(
        f"<li>Platform {p.id}: energy={p.energy:.1f}, "
        f"mat={p.material_stock:.1f}, comp={p.component_stock:.1f}, "
        f"prod={p.product_stock}, last={p.last_action}</li>"
        for p in s.platforms
    )
    return (
        f"<!DOCTYPE html><html><head><title>Space Manufacturing RL</title>"
        f"<style>body{{font-family:monospace;background:#06111f;color:#c8deff;padding:24px}}"
        f"h1{{color:#7cf7c9}}li{{margin:4px 0}}"
        f".warn{{color:#ffb86b;font-size:.85rem;margin-top:16px}}</style></head><body>"
        f"<h1>Space Manufacturing RL β€” {s.task_name}</h1>"
        f"<p>Step: {s.step_count} / {s.max_steps} | "
        f"Total reward: {s.total_reward:.2f} | Done: {s.done}</p>"
        f"<h2>Platforms</h2><ul>{platforms_html}</ul>"
        f"<p class='warn'>Frontend not built. Run: "
        f"cd frontend &amp;&amp; npm install &amp;&amp; npm run build</p>"
        f"</body></html>"
    )


# ── Entry point ────────────────────────────────────────────────────────────────

def main() -> None:
    """Start the uvicorn server with Windows-compatible event loop policy."""
    # Windows ProactorEventLoop raises spurious ConnectionResetError (WinError 10054)
    # when clients close keep-alive connections. Selector loop avoids the issue.
    if sys.platform == "win32":
        asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())

    uvicorn.run("SpaceFactory.server.app:app", host="0.0.0.0", port=8000, reload=False)


if __name__ == "__main__":
    main()