File size: 7,079 Bytes
cd6b93b
 
 
 
 
 
 
62068da
 
cd6b93b
 
 
 
62068da
cd6b93b
62068da
cd6b93b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
62068da
cd6b93b
 
 
62068da
 
cd6b93b
62068da
 
 
 
 
 
 
 
 
 
 
 
 
cd6b93b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
62068da
 
 
cd6b93b
 
 
 
 
 
 
 
 
 
 
 
 
62068da
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
cd6b93b
62068da
 
 
cd6b93b
 
 
62068da
 
 
cd6b93b
 
 
 
 
 
 
 
 
 
62068da
 
 
 
 
 
 
cd6b93b
62068da
 
83b8781
 
62068da
cd6b93b
 
62068da
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
cd6b93b
 
 
62068da
 
 
 
 
cd6b93b
 
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
"""FastAPI 路由与面板服务。

与 app.py 分离:app.py 负责 lifespan/agent 线程托管,本模块只管
HTTP 路由与文件读写。两者通过参数注入路径,便于测试与复用。
"""

import json
import secrets
import threading
import time
from pathlib import Path

from fastapi import FastAPI, Request
from fastapi.responses import HTMLResponse, JSONResponse, PlainTextResponse, Response

from status import atomic_write_json, read_status

_PANEL_HTML = Path(__file__).parent / "panel.html"


def _tail_lines(path, n=200):
    """读文件最后 n 行;文件不存在返回 []。"""
    try:
        with open(path, "rb") as f:
            data = f.read()
    except FileNotFoundError:
        return []
    except OSError:
        return []
    lines = data.decode("utf-8", errors="replace").splitlines()
    return lines[-n:]


def _read_commands(path):
    """读 commands.json;不存在或损坏返回 []。"""
    try:
        data = json.loads(Path(path).read_text(encoding="utf-8") or "{}")
        return data.get("commands", [])
    except (FileNotFoundError, ValueError, OSError):
        return []


def _write_commands(path, commands):
    """原子写 commands.json(先临时文件再 rename)。"""
    atomic_write_json(path, {"commands": commands})


def build_app(lifespan, *, status_path, commands_path,
              key_events_path, history_path, memory_path,
              web_token=None, command_lock=None):
    app = FastAPI(title="arena-evolve", lifespan=lifespan)
    command_lock = command_lock or threading.Lock()

    def _authorized(request):
        if not web_token:
            return True
        scheme, _, token = request.headers.get("Authorization", "").partition(" ")
        return scheme.lower() == "bearer" and secrets.compare_digest(token, web_token)

    def _unauthorized():
        return JSONResponse({"ok": False, "error": "unauthorized"},
                            status_code=401,
                            headers={"WWW-Authenticate": "Bearer",
                                     "Cache-Control": "no-store"})

    @app.get("/", response_class=HTMLResponse)
    def index():
        return HTMLResponse(_PANEL_HTML.read_text(encoding="utf-8"))

    @app.get("/api/health")
    def health():
        st = getattr(app.state, "stop_event", None)
        thr = getattr(app.state, "agent_thread", None)
        err = getattr(app.state, "agent_error", None)
        return {
            "ok": True,
            "agent_thread_alive": bool(thr and thr.is_alive()),
            "agent_error": err,
            "genes_loaded": getattr(app.state, "genes", None) is not None,
        }

    @app.get("/api/status")
    def status(request: Request):
        if not _authorized(request):
            return _unauthorized()
        # stale_after=60:超过 60s 未更新标记为 stale(agent 卡住/断线)
        snap = read_status(status_path, stale_after=60)
        if snap is None:
            return JSONResponse(
                {"kind": "none", "stale": True,
                 "agent_error": getattr(app.state, "agent_error", None)},
                status_code=200)
        snap["agent_error"] = getattr(app.state, "agent_error", None)
        snap["agent_thread_alive"] = bool(
            getattr(app.state, "agent_thread", None) and
            app.state.agent_thread.is_alive())
        return snap

    @app.get("/api/memory")
    def memory(request: Request):
        if not _authorized(request):
            return _unauthorized()
        headers = {
            "Cache-Control": "no-store",
            "Content-Disposition": 'attachment; filename="memory.json"',
        }
        try:
            raw = Path(memory_path).read_bytes()
        except FileNotFoundError:
            return JSONResponse({"error": "memory snapshot not found"},
                                status_code=404, headers=headers)
        except OSError:
            return JSONResponse({"error": "memory snapshot unavailable"},
                                status_code=503, headers=headers)
        try:
            data = json.loads(raw)
        except (ValueError, UnicodeDecodeError):
            return JSONResponse({"error": "memory snapshot unavailable"},
                                status_code=503, headers=headers)
        if not isinstance(data, dict):
            return JSONResponse({"error": "memory snapshot unavailable"},
                                status_code=503, headers=headers)
        return Response(content=json.dumps(data, ensure_ascii=False),
                        media_type="application/json", headers=headers)

    @app.get("/api/key-events")
    def key_events(request: Request, n: int = 80):
        if not _authorized(request):
            return _unauthorized()
        return {"lines": _tail_lines(key_events_path, n)}

    @app.get("/api/history")
    def history(request: Request, n: int = 50):
        if not _authorized(request):
            return _unauthorized()
        rows = []
        for line in _tail_lines(history_path, n):
            try:
                rows.append(json.loads(line))
            except ValueError:
                continue
        return {"rows": rows}

    @app.post("/api/command")
    async def command(request: Request):
        if not _authorized(request):
            return _unauthorized()
        try:
            body = await request.json()
        except ValueError:
            return JSONResponse({"ok": False, "error": "invalid JSON"},
                                status_code=400)
        ctype = body.get("type")
        allowed = ("mark_resource", "mark_obstacle", "goto",
                   "cancel_goto", "clear_marks", "remove_mark",
                   "core_migrate", "core_auto",
                   "core_station", "core_enable_migration")
        if ctype not in allowed:
            return JSONResponse({"ok": False, "error": f"unknown type: {ctype}"},
                                status_code=400)
        direction = body.get("direction")
        if ctype == "core_migrate" and direction not in (
                "UP", "DOWN", "LEFT", "RIGHT"):
            return JSONResponse({"ok": False, "error": "invalid direction"},
                                status_code=400)
        with command_lock:
            cmds = _read_commands(commands_path)
            if len(cmds) >= 256:
                return JSONResponse({"ok": False, "error": "command queue full"},
                                    status_code=429)
            cmds.append({
                "id": f"{time.time_ns()}",
                "type": ctype,
                "pos": body.get("pos"),
                "uid": body.get("uid"),
                "direction": direction,
            })
            _write_commands(commands_path, cmds)
        return {"ok": True, "pending": len(cmds)}

    @app.get("/api/commands")
    def commands(request: Request):
        if not _authorized(request):
            return _unauthorized()
        with command_lock:
            return {"commands": _read_commands(commands_path)}

    return app