Scaffold SimpleX Orchestrate: supervisor over official binaries

A standalone control-plane app that spawns and drives the official SimpleX
binaries (never modifies simplex source). Validated against simplex-chat
built from source (stable v6.5.4, GHC 9.6.3).

- CLAUDE.md: architecture notes mined from the upstream docs (WebSocket bot API,
  per-profile DBs, directory/broadcast bot config)
- supervisor/: process registry + port allocation (supervisor.py), corrId/cmd<->resp
  WebSocket client (ws_client.py), binary locator (binaries.py), FastAPI front with
  REST control + /events stream (server.py)
- smoke_test.py: Pattern-1 handshake (spawn simplex-chat -p, create+read user) — PASS
- group_test.py: two accounts, invitation connect + group invite/join, verified
  membership over the real SMP network — PASS
- build_chat.sh / install_ghc.sh: reproducible toolchain + from-source build

Key finding: fresh DB prompts for a display name on stdin; spawn with
--create-bot-display-name to start the WebSocket server non-interactively.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Jon
2026-06-04 12:31:37 +01:00
commit 38ff96c576
12 changed files with 704 additions and 0 deletions

90
supervisor/server.py Normal file
View File

@@ -0,0 +1,90 @@
"""Front-facing API the website talks to.
The browser:
- opens a WebSocket to /events to receive live chat events from every profile
(each event is tagged with the originating profile name);
- uses REST to spawn/stop profiles and to send commands to cli profiles.
This process sits between the website and the SimpleX binaries; it is the only
thing that touches the binaries. Run: uvicorn supervisor.server:app
"""
import asyncio
import contextlib
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
from .supervisor import Supervisor
app = FastAPI(title="SimpleX Orchestrate")
_browser_clients: set[WebSocket] = set()
async def _broadcast(name: str, event: dict) -> None:
dead = []
for ws in _browser_clients:
try:
await ws.send_json({"profile": name, "event": event})
except Exception:
dead.append(ws)
for ws in dead:
_browser_clients.discard(ws)
sup = Supervisor(on_event=_broadcast)
@app.on_event("shutdown")
async def _shutdown() -> None:
await sup.stop_all()
# ── Control (REST) ──────────────────────────────────────────────────────────────
@app.get("/profiles")
async def profiles() -> dict:
return {"profiles": sup.list()}
@app.post("/profiles/{name}/start-cli")
async def start_cli(name: str) -> dict:
await sup.start_cli(name)
return {"ok": True}
@app.post("/profiles/{name}/start-directory")
async def start_directory(name: str, body: dict) -> dict:
await sup.start_directory(name, body["super_users"], body["web_folder"])
return {"ok": True}
@app.post("/profiles/{name}/start-broadcast")
async def start_broadcast(name: str, body: dict) -> dict:
await sup.start_broadcast(name, body["display_name"], body["publishers"])
return {"ok": True}
@app.post("/profiles/{name}/cmd")
async def cmd(name: str, body: dict) -> dict:
"""Send a raw chat command string to a cli profile (e.g. '/_send @1 text hi')."""
return {"resp": await sup.send(name, body["cmd"])}
@app.post("/profiles/{name}/stop")
async def stop(name: str) -> dict:
await sup.stop(name)
return {"ok": True}
# ── Live events (WebSocket) ──────────────────────────────────────────────────────
@app.websocket("/events")
async def events(ws: WebSocket) -> None:
await ws.accept()
_browser_clients.add(ws)
try:
while True:
await ws.receive_text() # keep the socket open; ignore inbound
except WebSocketDisconnect:
pass
finally:
_browser_clients.discard(ws)