한 사이트의 플릿 매니저 · WebRTC 시그널링 게이트웨이
단일 사이트의 로봇 플릿을 한곳에서 보는 관제 서버이자, 운영자↔로봇 P2P 연결을 이어주는 WebRTC 시그널링 릴레이. 상태의 원본은 로봇(엣지)이 소유하고, NEXUS는 그 사본(뷰·캐시)만 들고 있는 Edge-Authoritative 설계다.
실제 로봇 없이 nexus_ui 스타일 화면에서 직접 조종해 볼 수 있습니다.
NEXUS 한 대 = 한 사이트의 플릿 매니저.상태의 진실은 로봇에 있고, 서버는 그 사본을 모아 보여줄 뿐이다.
NEXUS는 진실의 원본(SSOT)이 아니다. 각 로봇(TOM)이 자기 상태의 권위를 가지며, NEXUS는 WebSocket으로 받은 상태를 메모리에 캐시해 대시보드에 비춰주는 뷰다.
핵심 축은 둘이다. 첫째, 연결된 로봇들의 status를 모아 캐시하고 REST로 노출한다(/api/robots). 둘째, 운영자와 로봇이 직접 만나도록 WebRTC 시그널링을 중계한다(/ws/{room}). 핸드셰이크가 끝나면 미디어·DataChannel은 P2P로 직결되고 NEXUS는 데이터 경로에서 빠진다 — 작은 시그널링 JSON만 오간다.
이 두 축 위에 엣지 원칙을 지키는 얇은 서비스가 얹힌다 — FleetMission 조율(/api/fleet/*), 공간 메타(표준맵·로봇맵 정렬 부착 /api/maps·/api/alignments), 3D 트윈용 로봇 description(URDF/GLB) 캐시·서빙(/api/description), 컨트롤플레인 명령 릴레이(/api/command), IoT 알람 집계(/api/alarms), 흐름 추적 집계(/api/trace/* — 기본 꺼짐). 어느 것도 좌표 계산이나 미션 실행을 대신하지 않는다 — 여전히 부착·중계·모니터링만 한다.
의존성도 좁다. NEXUS가 import하는 것은 공통 표준 synapse 하나뿐이며(메시지 토픽 Topic 사용), tom_and_gerri나 tag_* 같은 로봇 코드는 전혀 import하지 않는다 — 코드 결합도 0, 오직 네트워크 메시지 표준으로만 대화한다.
/api/robots는 server_time을 함께 내려주고, 클라이언트가 server_time − last_seen > ttl 로 스스로 판단한다.websocketspersistence.py, stdlib sqlite3 최소 구현) — NEXUS_DB_PATH로 켬(run.sh 기본 ON). per-robot 이력(history) seam 배선됨 — NEXUS_HISTORY로 opt-in(실 백엔드는 이관 영역)python -m nexus 단일 명령 · 빌드 없음 — 서버 배포는 ./setup.sh 한 번으로 설치·systemd 등록·기동·헬스체크까지 끝난다synapse import (tom_and_gerri·tag 미import → 결합도 0)Figure 1. NEXUS 핵심 사실. 상태 원본은 로봇이 소유하고 NEXUS는 뷰/캐시 — 메모리 레지스트리가 기본이고, 재시작 생존용 SQLite 스냅샷 영속은 run.sh 기본 ON이다.
실시간 제어는 엣지에, 미디어는 P2P에 맡기므로 NEXUS는 IO 중심이다.async·WebSocket·OpenAPI를 기본 제공하는 FastAPI + Uvicorn 한 켤레로 충분하다.
패키지는 여전히 단순하다. __main__.py가 uvicorn.run(create_app())을 띄우고, app.py의 create_app() 팩토리가 라우트와 RelayHub·RobotRegistry를 조립한다. relay.py는 룸 기반 시그널링 릴레이, registry.py는 (robot_id, sub_id) 상태 캐시, fleet.py는 FleetMission DAG 조율기, persistence.py는 SQLite 영속 seam, gateway.py는 콕핏 정적 서빙 + config 주입, config.py는 정적 Pydantic 설정을 담당한다(그 외 ratelimit.py·logging_config.py). npm·webpack 같은 빌드 단계는 없다.
# synapse(공통 표준)는 editable 설치 — tom_and_gerri/tag 는 설치/그 import 불필요
pip install -e ./synapse
pip install -e ./nexus # fastapi · uvicorn · websockets · pydantic>=2
# 서버 기동 (기본 http://0.0.0.0:8000)
python -m nexus
python -m nexus --host 0.0.0.0 --port 8000
# 서버(Linux+systemd) 배포는 원커맨드 — 형제 clone·venv·설치·서비스 등록·기동·healthz 확인까지
./setup.sh # 재실행 안전 · SETUP_NO_SERVICE=1 이면 서비스 단계 생략
./run.sh # 개발 PC 포그라운드 실행 — nexus.env 있으면 자동 로드
Figure 2. python -m nexus 한 줄이 전부다. --host/--port는 선택, 기본값은 0.0.0.0:8000. 서버 배포에서는 setup.sh가 systemd 유닛의 User·경로를 자동 치환해 상시 가동까지 맡고, 배포별 환경변수는 nexus.env(gitignore) 한 파일로 주입된다(run.sh·systemd 양쪽 자동 로드).
배포 환경에서는 같은 프로세스를 컨테이너로 올린다. 빌드 컨텍스트는 형제 레포가 모인 부모 디렉터리(new_system/)로 두는데 — 비공개 패키지 synapse를 로컬 설치로 먼저 충족한 뒤 nexus를 설치해야 from synapse import …가 깨지지 않기 때문이다. 이미지는 python:3.12-slim 위에 두 패키지를 얹고 포트 8000을 EXPOSE한다. compose는 볼륨 ./data:/data로 맵 PNG·SQLite registry를 영속하고, NEXUS_UI_DIR·NEXUS_MAPS_DIR·NEXUS_DB_PATH 환경변수와 restart: unless-stopped를 건다.
# 빌드 + 백그라운드 기동 (context 는 부모 — synapse 를 함께 담는다)
docker compose -f nexus/docker-compose.yml up --build -d
# 헬스체크
curl -sf http://localhost:8000/healthz
Figure 3. 컨테이너 운영. 빌드 컨텍스트를 부모로 둬 비공개 synapse를 로컬 설치로 먼저 충족한 뒤 nexus를 설치하고, ./data 볼륨으로 맵·registry를 재시작 너머 보존한다.
| 모듈 | 역할 |
|---|---|
| __init__.py | 공개 API — RelayHub · RobotRegistry · load_config · SystemConfig를 export. |
| __main__.py | python -m nexus 진입점 — uvicorn.run(create_app())으로 서버를 띄운다. |
| app.py | FastAPI create_app() 팩토리 + 모든 라우트 조립. |
| relay.py | RelayHub(룸 기반 room = robot_id 시그널링 릴레이) + DashboardHub(대시보드 push). |
| registry.py | RobotRegistry — (robot_id, sub_id) in-memory status 상태 캐시. |
| fleet.py | FleetMissionRunner — depends_on DAG 검증·조율, run_view 발행. |
| persistence.py | 영속 seam — NullStore(기본) / SqliteStore(stdlib sqlite3, 스냅샷 + 이력). |
| trace.py | TraceGraph — trace.hop 수집·집계(노드/엣지 + 개수 상한·TTL). 기본 꺼짐 — 로봇이 켠 만큼만 산다. |
| config.py | SystemConfig / SiteConfig / FloorConfig (pydantic, 정적) — 배포마다 다른 IceServer(STUN/TURN)만 NEXUS_ICE_SERVERS 환경변수로 주입. |
| gateway.py | 콕핏 정적 서빙 + window.__COCKPIT__ 주입. |
| ratelimit.py · logging_config.py | /api/command 릴레이 rate-limit · 로깅 설정. |
| cockpit_default/ | tag_{robot}/ 폴더가 없을 때의 폴백 콕핏. |
Figure 4. 패키지 레이아웃 — 코어 모듈 + 폴백 콕핏. 영속은 persistence.py의 stdlib sqlite3 최소 구현으로, 별도 무거운 DB 의존성 없이 스냅샷/이력 seam을 제공한다.
app.py가 노출하는 표면 — 상태·description·맵/정렬·플릿·제어·IoT·흐름추적 REST + 콕핏 게이트웨이 + WS 3개(/ws/dashboard·/ws/trace·/ws/{room}).site/building/floor는 URL 네임스페이스일 뿐, 콕핏 폴더는 robot id로 고른다.
| 메서드 | 경로 | 반환 · 동작 |
|---|---|---|
| 상태 · 설정 | ||
| GET | /api/robots | {"robots":[{robot_id, subs:{sub_id:entry}, last_seen}…], "server_time":float} 등록 로봇을 robot_id로 묶어 반환. 클라이언트가 server_time − last_seen으로 OFFLINE 추론. |
| GET | /api/sites | SystemConfig.model_dump() 사이트 → 건물 → 층(floor) → 맵 트리. 정적 config를 그대로 직렬화. |
| GET | /healthz | 헬스체크 — uptime · 등록 로봇 수. |
| POST | /register_robot/{room} | rtc2kng 호환용 ack — no-op이다. NEXUS는 /ws/{room} 합류 시 자동 등록이라 실제 동작은 없고, 등록 POST를 보내는 클라이언트에 200으로 답해 404 노이즈를 없앤다(드롭인 호환). 실제 등록·라우팅은 로봇이 보내는 robot.status로 이뤄진다. |
| 로봇 description (3D 디지털 트윈) | ||
| GET | /api/description/{robot_type} | 로봇 URDF/GLB 본문 서빙(디렉터리형 model.urdf → 플랫 폴백, 없으면 404). |
| POST | /api/description/{robot_type} | 로봇 → 서버 URDF + 메시 zip 업로드/캐시(3D 트윈용). 가드: basename·60MB·zip-slip·엔트리 상한·model.urdf 또는 model.glb(모바일 일체형) 필수·원자적 교체. |
| GET | /api/descriptions | 캐시된 description 매니페스트(index.json). |
| 공간 — 표준맵 · 정렬 | ||
| GET POST DEL | /api/maps · /api/maps/{id} | 표준맵 목록(+image_url) · 맵 에디터 업로드(JSON+base64 PNG) · 업로드 맵 삭제(config 맵은 불가). |
| GET POST DEL | /api/alignments | 로봇맵→표준맵 정렬(SE(2) to_standard=[tx(m), ty(m), θ(deg)]) 조회·등록·삭제. status 수신 시 standard_map_id+to_standard를 부착만 한다(좌표 계산은 UI). 등록(POST)은 standard_map_id가 실제 등록된 표준맵(config + 업로드맵)인지 검증 → 아니면 400(dangling 방지). 맵을 보고하지 않는 로봇도 (robot_id, '*') 정렬로 표준맵 위에 배치할 수 있다. 저장 시 to_standard의 자릿수를 정리한다 — 마법사가 계산한 SE(2)의 부동소수 꼬리(-33.33006134969326)가 그대로 저장돼 있었고, 이 값은 로봇 발행 경로를 안 타서 gerri 자릿수 게이트가 못 본다 — 저장하는 여기가 유일한 초크포인트다. θ를 deg 자릿수 정책(WIRE_DECIMALS["deg"])으로 접는 것이 이 필드의 단위 선언이기도 하다 — 만드는 쪽이 nexus_ui(JS)라 synapse 타입이 강제하지 못하고, angle_unit 브릿지도 로봇 발행 경로만 보므로 여기는 사각지대다(2026-08-18: 맵 에디터 창 하나가 θ를 rad로 접어 저장하고 있었다 — 90 입력 → 1.57 저장 → 콘솔은 1.57°로 해석). |
| 플릿 미션 (FleetMissionRunner) | ||
| GET POST | /api/fleet/templates | 저장된 FleetMission 템플릿 목록 · 빌더가 만든 미션을 missions/*.json으로 저장(DAG 검증). |
| GET POST | /api/fleet/missions | 진행/완료 런 목록 · 시작({fleet_mission} 인라인 또는 {template}+args). |
| GET POST | /api/fleet/missions/{run_id} · …/abort | 런 단건 조회(run_view) · 중단(진행 로봇에 stop 발송). |
| 제어 · IoT | ||
| POST | /api/command | 컨트롤플레인 명령 릴레이 — 게이트키핑(봉투검증 400 → 미연결 404 → rate-limit 429 → 전달 200). |
| GET POST DEL | /api/placements · /api/placements/{robot_id} | 설비(IoT: 엘리베이터·자동문 등) 설치 좌표(표준맵 world m) 목록 · 등록/갱신 · 삭제. {robot_id, map_id, x, y, theta} — 설비는 pose를 스스로 보고하지 않으므로 서버가 위치를 소유한다(placements.json 영속). 엘리베이터는 관통하는 맵마다 등록. |
| GET | /api/alarms · /api/ice | 활성 IoT 알람 집계(io.is_alarm) · WebRTC ICE(STUN/TURN) 설정 — TURN은 요청 시마다 시간제한 크리덴셜 생성(coturn use-auth-secret), server_time 동봉. |
| 흐름 추적(trace) · 기본 꺼짐 | ||
| GET | /api/trace/graph | 흐름 그래프 스냅샷 — 노드=참여자(operator:{id}·nexus·robot:{id}/{layer}), 엣지=(source, target, topic)별 관측 흐름(최근 60초 창의 메시지/초 · p50/p95 지연 · 정규화 탈락 필드 · 마지막 hop). 로봇이 안 켰으면 빈 그래프다 — 에러가 아니다. |
| GET | /api/trace/message/{uuid} | 한 메시지(msg_info.uuid)의 end-to-end hop 타임라인. 링버퍼라 최근 것만 남는다 → 없으면 404. |
| WS | /ws/trace | 흐름 추적 증분 push — 연결 시 전체 snapshot, 이후 배치가 건드린 노드/엣지만 보낸다(/ws/{room}보다 먼저 정의 — 와일드카드 가로채기 방지). |
| 콕핏 게이트웨이 · WebSocket · 정적 | ||
| GET | /{site}/{building}/{floor}/tag_{robot}/… | 콕핏 index.html + window.__COCKPIT__ = {robotId, signalingUrl, mode} 주입 · 정적 자산 서빙(traversal 차단). tag 폴더 없으면 cockpit_default 폴백. |
| WS | /ws/dashboard | 대시보드 실시간 push(/ws/{room}보다 먼저 정의 — 와일드카드 가로채기 방지). |
| WS | /ws/{room} | room = robot_id. 통합 시그널링 릴레이 + 상태 캐시. 운영자·로봇이 같은 room에서 메시지 교환. |
| GET | /console · /maps · /descriptions | 정적 마운트 — nexus_ui 콘솔 · 표준맵 이미지 · URDF/메시(상대경로 해석). |
Figure 5. 전체 API 표면. signalingUrl은 요청 Host로부터 ws(s)://{host}/ws/{robot} 형태로 만들어 콕핏에 주입된다.
단일 엔드포인트 /ws/{room}가 메시지 종류에 따라 캐시 / 브로드캐스트 / 1:1 라우팅 / 컨트롤플레인 중계 / 흐름 추적 수집으로 갈린다.시그널링 JSON은 해석 없이 원문 그대로 전달된다.
registry.upsert_status(value)로 (robot_id, sub_id) 엔트리를 갱신. 대시보드용 사본일 뿐 다른 피어에 전달하지 않는다.hub.register_sender(sender, ws)로 sender id를 등록하고, receiver가 없으므로 같은 room의 나를 제외한 모두에게 전파.command.* 봉투(receiver ≠ room)를 보내면 /api/command와 동일 게이트키핑(미연결 경고 · rate-limit 429)으로 대상 피어에 중계. 로봇이 IoT·타 노드를 조작할 때 별도 HTTP가 필요 없다.TraceGraph.record_batch로 접고 /ws/trace 구독자에게 증분만 push. 분기는 status 분기 뒤에 둔다 — 10~50Hz 핫패스에 비교 한 줄도 더하지 않는다(관측이 대상을 바꾸면 안 된다).Figure 6. RelayHub.targets() + app WS 핸들러의 분기. receiver가 있으면 그 피어에게만, 없으면 room 전체로. status는 라우팅에서 빠져 캐시로 흡수되고, command.*(receiver ≠ room)는 게이트키핑을 거쳐 대상 피어로 중계되며, trace.hop은 릴레이 없이 집계기로만 간다.
sender / receiver)만 보고 JSON 원문을 그대로 중계한다 — SDP·ICE 페이로드는 들여다보지 않는다.robot.status만은 라우팅 대상에서 빠져 레지스트리에 캐시만 된다.대시보드로의 실시간 push는 별도 /ws/dashboard가 담당한다(DashboardHub).운영자의 ready 신호를 받은 로봇이 offer를 만든다.설계상 NEXUS는 시그널링까지만 — 핸드셰이크 후 미디어·데이터는 P2P로 직결된다.
Figure 7. 시그널링은 NEXUS를 거치지만, 연결이 서면 데이터 경로에서 NEXUS는 빠진다.
{type:"ready", sender:…}로 입장. NEXUS는 sender를 등록하고 room의 로봇에게 브로드캐스트한다.receiver를 운영자로 지정 → NEXUS가 1:1로 전달.receiver 지정 1:1 라우팅.candidate 메시지를 서로 주고받으며 경로를 협상. 모두 receiver 지정 1:1.Figure 8. Robot-first 핸드셰이크 단계. ready만 브로드캐스트, offer/answer/candidate는 receiver 지정 1:1 라우팅이다.
대칭 NAT·사내 방화벽 뒤에서는 STUN만으로 P2P가 서지 않는다. 이때 TURN 릴레이가 필요한데, TURN 계정을 클라이언트에 고정으로 박아두면 노출된 순간 누구나 릴레이를 쓴다. 그래서 NEXUS는 /api/ice에서 coturn의 TURN REST API(use-auth-secret) 규약으로 요청 시마다 시간제한 크리덴셜을 만들어 내려준다 — username = 「만료 epoch:nexus」, credential = base64(HMAC-SHA1(secret, username)). 서버가 쥔 auth_secret 자체는 응답에 절대 나가지 않는다.
만료가 있으므로 클라이언트는 캐시하지 않고 통화 직전에 다시 조회한다 — 콕핏(브라우저)은 1:1 진입 시점에, 로봇(rtc2kng)은 연결을 만들 때마다 /api/ice를 부른다(조회 실패는 정적 설정 → 공용 STUN 순으로 폴백해 연결 시도 자체는 막지 않는다). 응답에는 시계 스큐 판단용 server_time이 동봉된다. 정적 계정(coturn lt-cred-mech)을 쓰는 배포는 auth_secret 대신 username/credential을 그대로 넣으면 된다.
# coturn 설치 → 시크릿 생성 → turnserver.conf → 서비스 기동 → nexus.env 작성까지 한 번에(재실행 안전)
sudo bash scripts/setup_turn.sh # 공인 IP 자동 감지 (또는 setup_turn.sh turn.keti.kr)
sudo systemctl restart nexus # nexus.env(EnvironmentFile) 자동 로드
curl -s http://localhost:8000/api/ice # username 이 '만료epoch:nexus' 형태면 정상
# 수동 구성 시 — 배포별 ICE 설정은 환경변수 하나로 주입(JSON 리스트)
NEXUS_ICE_SERVERS='[{"urls":"stun:stun.l.google.com:19302"},
{"urls":["turn:turn.keti.example:3478?transport=udp","turns:turn.keti.example:5349"],
"auth_secret":"<coturn static-auth-secret 과 동일>","ttl_s":600}]'
Figure 9. TURN 배포. scripts/setup_turn.sh가 coturn 설치부터 nexus.env 작성까지 맡고, 사람이 직접 여는 것은 방화벽뿐이다(3478/udp·tcp, 릴레이 범위 49152–65535/udp, TLS는 5349/tcp). 파싱 실패 시 기본 STUN으로 계속 떠서 서버 기동을 막지 않는다.
레지스트리는 (robot_id, sub_id) 키로 status를 upsert한다.보통 1 로봇 = 1 base = 1 피어지만, 드물게 물리적으로 분리된 디바이스는 별도 sub로 발행한다.
한 로봇이 여러 sub(또는 분리 디바이스)로 status를 따로 발행해도, /api/robots는 이를 robot_id 기준으로 묶어 한 로봇처럼 보여준다. 묶인 그룹의 last_seen은 sub들 중 가장 최신 시각이다. 드물게 물리적으로 갈라진 경우에는 mobile_01.arm 같은 피어 id로 구분한다. robot_id가 없는 status는 라우팅 불가로 폐기되며(경고 로그), 잘못된 메시지가 서버를 죽이지 않게 한다.
| 필드 | 설명 |
|---|---|
| robots[] | 등록된 로봇 배열. 각 항목은 robot_id로 묶인 한 로봇. |
| robots[].robot_id | 로봇 식별자 (그룹 키). |
| robots[].subs | { sub_id: entry } 맵. 각 entry는 robot_info · robot_state · mission_info를 담는다. |
| robots[].last_seen | float — 묶인 sub들 중 가장 최신 수신 시각. |
| server_time | float — 응답 생성 시각. 클라이언트의 OFFLINE 판정 기준. |
Figure 10. /api/robots 응답 구조. 각 sub 엔트리는 robot_info · robot_state · mission_info를 담고, 상위에 server_time이 동봉된다.
server_time − last_seen > ttl 인지로 스스로 판단한다.robot_info·robot_state·mission_info 3개만 뽑아 저장해서, 로봇이 value 최상위에 실은 나머지 블록이 조용히 잘렸다. 실제로 capability_info(진행 표시)가 이 지점에서 소실됐고 — 로봇도 gerri도 정상 발행 중인데 콘솔에만 안 보여서 원인 추적이 어려웠다. 릴레이는 계약을 해석하지 말고 실어 나른다 — Postel's Law._omit_unchanged — robot_info가 와이어의 61%인데 거의 안 변한다). 교체로 두면 생략된 틱마다 capability·presets·version이 사라졌다 다시 나타나 콘솔이 깜빡였다. 규칙은 한 줄이다 — 온 블록은 그대로 쓰고, 안 온 블록은 직전 것을 유지. 예외는 robot_info 하나로, 라우팅 키가 항상 실려 오므로 이 블록만 키 단위로 병합한다.robot_info.angle_unit="rad"를 선언하면 upsert_status가 to_standard_angles()로 deg로 접는다(멱등). 그 뒤로 registry·API·콘솔은 전부 deg만 본다. 이건 gerri를 안 쓰는 로봇(plain class·외부 스택)을 위한 안전망이다 — gerri 로봇은 이미 발행 시점에 접어서 보낸다(synapse 각도 표준). 정규화가 실패해도 status 수신을 죽이지 않는다(워닝 후 원본 저장).config.py의 SITES dict를 시작 시 한 번 SystemConfig로 검증한다..env·핫리로드 없음 — 값을 바꾸면 서버를 재시작한다.예: {"itp": {"name":"인천테크노파크", "floors":{"1f":…, "2f":…}}}.다중 로봇 조율이 구현됐다(Phase 3).NEXUS가 FleetTask DAG를 조율해 로봇에 미션을 던지고 robot.status.mission_info로 진행을 추적한다 — 실행 주체는 여전히 로봇(엣지)이다.
nexus/fleet.py의 FleetMissionRunner가 FleetTask DAG를 실행하고, /api/fleet/* REST API로 시작·조회·중단한다(단위 테스트 tests/test_fleet.py 포함).NEXUS는 스케줄링을 조율(orchestrate)한다. FleetMissionRunner가 depends_on 기반 DAG를 풀어, 의존 task가 모두 성공하면 해당 로봇에 mission.trigger를 던진다. 완료 추적은 robot.status.mission_info 폴링 — mission_state가 TERMINAL(SUCCEEDED/FAILED/ABORTED)이 되면 task 종료로 본다(Status가 SSOT). 미션 실행 주체는 로봇(TOM)이고, 데이터 모델 FleetMission은 synapse에 정의된다(NEXUS에 두지 않는다).
한 로봇은 한 번에 한 미션만 수행하므로, 같은 로봇을 대상으로 한 task는 의존이 없어도 자동 직렬화된다. 실패 정책 on_failure(stop · continue · retry · help)와 timeout을 지원하고, 진행상황은 run_view로 대시보드에 broadcast된다. 각 task의 skip/실패에는 기계판독 reason 코드(FleetTaskReason — dep_failed·upstream_stop·trigger_undelivered·fleet_timeout 등)와 사람용 detail이 분리돼 붙어, 제3 UI(Unity 등)도 동일 계약으로 분기·로컬라이즈할 수 있다. 요약하면 — NEXUS는 던지고 지켜보고, 로봇이 실행한다. 점유·실패 복구 같은 실행 책임을 엣지에 남기는 것이 Edge-Authoritative 원칙과 일관된다.
| 컴포넌트 | 책임 · 구현됨 |
|---|---|
| FleetMissionRunner | nexus/fleet.py. depends_on 기반 DAG를 풀어 다중 로봇 미션을 조율(orchestrate). 예: 이동 → 제조 → 전달 → 배송 순서를 의존성으로 묶는다. validate_dag가 의존성 존재·사이클을 사전 검증한다. |
| /api/fleet/* | REST — GET/POST templates(빌더 저장, DAG 검증) · GET/POST missions(인라인 fleet_mission 또는 template+args로 시작) · GET missions/{id} · POST missions/{id}/abort. |
| FleetMission (데이터 모델) | synapse.missions에 정의 — NEXUS가 소유하지 않는다. |
| NEXUS의 역할 | mission.trigger로 미션을 던지고, robot.status.mission_info 폴링으로 진행을 모니터링한다(콜백 send·get_mission_info 주입 구조). |
| 로봇 (TOM)의 역할 | 실제 Task 실행 · 점유 · 실패 복구 — 실행 책임은 엣지에 남는다. |
Figure 11. FleetMission 조율 — 구현됨(Phase 3). NEXUS는 DAG를 풀어 미션을 트리거하고 mission_info로 완료를 추적하며, 실제 실행은 로봇(엣지)이 맡는다.
한 명령이 운영자 → NEXUS → AND → base → sub → 메서드를 지나는 동안 어디서 무엇이 되었는지 한 화면에서 보게 한다.계약(TraceHop·TraceBatch)은 synapse가 소유하고, 발행은 GERRI/AND, 집계는 NEXUS다 — nexus/trace.py의 TraceGraph.
노드는 참여자, 엣지는 관측된 흐름이다. hop 1건은 “참여자 하나를 지나갔다”는 사실일 뿐이므로, 같은 msg_info.uuid의 앞뒤 hop을 at 순으로 이어 직전 hop → 이 hop을 엣지로 센다.
NEXUS도 자기가 와이어에서 본 것만 얹는다. 로봇만 hop을 만들면 노드가 언제나 robot:{id}/{layer}뿐이라 nexus도 operator:{id}도 그래프에 영원히 나타나지 않고, 간판인 end-to-end가 로봇 내부로 쪼그라든다. 얹는 무게는 토픽 정책으로 가른다(정책은 synapse가 미리 고정 — 판단이 두 곳에 있으면 드리프트한다).
| 정책 | 토픽 | NEXUS가 하는 일 |
|---|---|---|
| 틱(tick) | robot.status · command.teleop | 10~50Hz라 hop을 만들지 않는다 — 창 카운터(dict 조회 + 정수 증가)만 올리고 1초 창마다 엣지 관측 1건으로 접는다(count=창의 건수). |
| 건별(per-message) | command.capability · mission.trigger · mission.event · help.action | 사람이 누를 때만 생기는 저빈도 → 그때만 실제 TraceHop을 만든다. uuid로 end-to-end를 꿴다. |
| 정책 없음 | trace.hop 자신 | 아무 일도 하지 않는다 — hop이 hop을 낳는 증폭을 차단한다(관측이 대상을 바꾸는 최악의 형태). |
Figure 12. 관측 샘플링 정책. 실측 근거 — command.teleop을 건별로 추적하면 hop 트래픽이 원본 업링크의 148%였다. 사용자 지침도 “최대한 가볍게, 토픽 정도만”이었다.
/api/command 릴레이 둘이다. 후자를 빼먹으면 operator 노드가 아예 안 나타난다 — 콕핏의 send()는 P2P DataChannel을 먼저 쓰고 미개통이면 그 HTTP로 폴백하기 때문이다. registry.upsert_status는 건드리지 않는다(핫패스). P2P로 직행한 명령은 NEXUS를 지나지 않으므로 여기서 보이지 않고, 로봇쪽 AND가 자기 RX hop으로 기록한다.OrderedDict 순서가 곧 LRU라 상한 초과 시 앞에서 버리고, TTL 정리도 앞에서 훑다가 살아있는 첫 항목에서 멈춘다. 시계: 빈도·TTL은 서버 수신 시각으로 세고(로봇 시계가 틀어져도 msg/s는 정확해야 한다), 지연은 발행자가 계산한 latency_ms를 우선 쓴다. 음수(시계 역전)는 표본에서 뺀다. 발신 시각이 수신 시각보다 앞서면 수신 시각으로 눕힌다 — 없는 흐름을 그리는 것이 가장 나쁜 관측이다.robot.status와 가끔 오는 command.capability를 함께 담으면 p50 지연도 “마지막 1건”도 status가 다 덮어써 의미가 사라진다. 접어 보는 것은 소비자 몫이다 — /api/robots와 같은 원칙으로 서버는 전체를 준다. 같은 노드끼리(in→out, 처리 시간)는 엣지로 만들지 않는다 — self-loop이 그래프를 덮기 때문이고, 그 구간은 /api/trace/message/{uuid}의 원본 타임라인에 그대로 남는다.dropped_fields다: 엣지에 정규화가 떨군 필드 경로가 실린다 — “내 필드가 왜 콘솔에 안 보이지?”에 답하는 유일한 데이터다. 종전에 그걸 아는 곳은 발행측 워닝 로그뿐이었고, 그마저 경로당 60초 1회로 스로틀돼 사실상 안 보였다.