Complete DDS training workflow and delivery package
This commit is contained in:
parent
68dd83c7c2
commit
4c4b91064f
229 changed files with 11969 additions and 1024 deletions
197
scripts/smoke_active_session_recovery.py
Normal file
197
scripts/smoke_active_session_recovery.py
Normal file
|
|
@ -0,0 +1,197 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Живой smoke: активная карточка ДДС переживает restart backend.
|
||||
|
||||
Скрипт сам запускает два последовательных uvicorn-процесса на одном порту,
|
||||
использует один локальный PostgreSQL и проверяет карточку, статус службы,
|
||||
выбранную бригаду, письменный ответ и продолжающийся таймер.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
from uuid import uuid4
|
||||
|
||||
import websockets
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
BACKEND = ROOT / "backend"
|
||||
|
||||
|
||||
def request_json(url: str, *, method: str = "GET", cookie: str = "") -> tuple[dict, str]:
|
||||
request = urllib.request.Request(url, method=method)
|
||||
if cookie:
|
||||
request.add_header("Cookie", cookie)
|
||||
with urllib.request.urlopen(request, timeout=5) as response:
|
||||
value = json.loads(response.read())
|
||||
issued = response.headers.get("set-cookie", "").split(";", 1)[0]
|
||||
return value, issued
|
||||
|
||||
|
||||
def wait_ready(base: str, timeout: float = 20) -> dict:
|
||||
deadline = time.monotonic() + timeout
|
||||
last: Exception | None = None
|
||||
while time.monotonic() < deadline:
|
||||
try:
|
||||
health, _ = request_json(f"{base}/api/health")
|
||||
return health
|
||||
except (OSError, urllib.error.URLError, json.JSONDecodeError) as exc:
|
||||
last = exc
|
||||
time.sleep(0.1)
|
||||
raise RuntimeError(f"backend не стал готов: {last}")
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def backend_process(port: int, database_url: str, *, hard_stop: bool = False):
|
||||
env = {
|
||||
**os.environ,
|
||||
"DATABASE_URL": database_url,
|
||||
"OFFLINE": "true",
|
||||
"VOICE_ENABLED": "false",
|
||||
"DEV_AUTH_BYPASS": "true",
|
||||
"DEMO_NO_DB": "false",
|
||||
"SESSION_SECRET": "recovery-smoke-secret-that-is-not-production",
|
||||
"LLM_API_KEY": "",
|
||||
"LLM_PROVIDER": "disabled",
|
||||
}
|
||||
process = subprocess.Popen(
|
||||
[sys.executable, "-m", "uvicorn", "app.main:app", "--host", "127.0.0.1",
|
||||
"--port", str(port), "--workers", "1"],
|
||||
cwd=BACKEND,
|
||||
env=env,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
)
|
||||
try:
|
||||
wait_ready(f"http://127.0.0.1:{port}")
|
||||
yield process
|
||||
finally:
|
||||
if process.poll() is None:
|
||||
process.kill() if hard_stop else process.terminate()
|
||||
try:
|
||||
process.wait(timeout=8)
|
||||
except subprocess.TimeoutExpired:
|
||||
process.kill()
|
||||
process.wait(timeout=5)
|
||||
if not hard_stop and process.returncode not in (0, -15):
|
||||
error = process.stderr.read()[-4000:] if process.stderr else ""
|
||||
raise RuntimeError(f"backend завершился с кодом {process.returncode}: {error}")
|
||||
|
||||
|
||||
async def receive_type(socket, wanted: str) -> dict:
|
||||
for _ in range(30):
|
||||
value = json.loads(await asyncio.wait_for(socket.recv(), timeout=5))
|
||||
if value.get("type") == wanted:
|
||||
return value
|
||||
raise AssertionError(f"событие {wanted} не получено")
|
||||
|
||||
|
||||
async def start_and_edit(ws_base: str, cookie: str, session_id: str) -> dict:
|
||||
options = {"additional_headers": {"Cookie": cookie}}
|
||||
async with websockets.connect(f"{ws_base}/ws/control/{session_id}", **options) as control:
|
||||
await control.send(json.dumps({
|
||||
"type": "scenario.start",
|
||||
"scenario_id": "fire-apartment-l2",
|
||||
"trainee": "Recovery smoke",
|
||||
"mode": "training",
|
||||
"exercise": "dds",
|
||||
"criteria": {
|
||||
"decision_time_limit_seconds": 45,
|
||||
"allowed_errors": 0,
|
||||
"require_correct_grammar": True,
|
||||
},
|
||||
}))
|
||||
await asyncio.sleep(0.4)
|
||||
|
||||
async with websockets.connect(f"{ws_base}/ws/station/{session_id}", **options) as station:
|
||||
card = await receive_type(station, "card.received")
|
||||
snapshot = (await receive_type(station, "station.state"))["snapshot"]
|
||||
service = snapshot["services"][0]
|
||||
crew = snapshot["crew_options"][0]
|
||||
await station.send(json.dumps({
|
||||
"type": "card.status", "service": service, "status": "accepted",
|
||||
}))
|
||||
await receive_type(station, "station.state")
|
||||
await station.send(json.dumps({"type": "crew.select", "crew": crew}))
|
||||
await receive_type(station, "station.state")
|
||||
await station.send(json.dumps({
|
||||
"type": "card.reply",
|
||||
"card_id": card["card"]["card_id"],
|
||||
"text": "Сообщение принято, бригада направлена.",
|
||||
}))
|
||||
latest = (await receive_type(station, "station.state"))["snapshot"]
|
||||
return {"service": service, "crew": crew, "card_id": card["card"]["card_id"],
|
||||
"before": latest}
|
||||
|
||||
|
||||
async def verify_and_finish(ws_base: str, cookie: str, session_id: str, expected: dict) -> dict:
|
||||
options = {"additional_headers": {"Cookie": cookie}}
|
||||
async with websockets.connect(f"{ws_base}/ws/station/{session_id}", **options) as station:
|
||||
card = await receive_type(station, "card.received")
|
||||
snapshot = (await receive_type(station, "station.state"))["snapshot"]
|
||||
assert card["card"]["card_id"] == expected["card_id"]
|
||||
assert snapshot["statuses"][expected["service"]] == "accepted"
|
||||
assert snapshot["crew_selected"] == expected["crew"]
|
||||
assert snapshot["reply_text"] == "Сообщение принято, бригада направлена."
|
||||
await station.send(json.dumps({"type": "station.finish"}))
|
||||
await receive_type(station, "score.ready")
|
||||
return snapshot
|
||||
|
||||
|
||||
async def run(args) -> dict:
|
||||
http_base = f"http://127.0.0.1:{args.port}"
|
||||
ws_base = f"ws://127.0.0.1:{args.port}"
|
||||
session_id = str(uuid4())
|
||||
async with backend_process(args.port, args.database_url, hard_stop=True):
|
||||
_, cookie = request_json(f"{http_base}/api/auth/dev-token", method="POST")
|
||||
expected = await start_and_edit(ws_base, cookie, session_id)
|
||||
|
||||
# Небольшая пауза делает проверяемым продолжение активного таймера.
|
||||
await asyncio.sleep(1.2)
|
||||
async with backend_process(args.port, args.database_url):
|
||||
health = wait_ready(http_base)
|
||||
assert health["sessions_restored"] >= 1
|
||||
# Та же подписанная cookie должна пережить restart. При смене роли,
|
||||
# пароля или блокировке её отзовёт сохранённый auth_version.
|
||||
after = await verify_and_finish(ws_base, cookie, session_id, expected)
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"session_id": session_id,
|
||||
"sessions_restored": health["sessions_restored"],
|
||||
"card_id": after["card_id"],
|
||||
"status": after["statuses"][expected["service"]],
|
||||
"crew": after["crew_selected"],
|
||||
"reply_text": after["reply_text"],
|
||||
}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--port", type=int, default=18116)
|
||||
parser.add_argument(
|
||||
"--database-url",
|
||||
default=os.environ.get(
|
||||
"DATABASE_URL", "postgresql+asyncpg://lct:lct@127.0.0.1:5434/lct"
|
||||
),
|
||||
)
|
||||
args = parser.parse_args()
|
||||
try:
|
||||
print(json.dumps(asyncio.run(run(args)), ensure_ascii=False, indent=2))
|
||||
except Exception as exc: # noqa: BLE001
|
||||
print(json.dumps({"ok": False, "error": str(exc)}, ensure_ascii=False), file=sys.stderr)
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Loading…
Reference in a new issue