Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/code-quality.yml
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ jobs:
- uses: astral-sh/setup-uv@v7
with:
enable-cache: true
- run: uv sync --locked --all-extras --dev
- run: uv run scripts/test
build:
runs-on: ubuntu-latest
Expand Down
5 changes: 4 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -167,4 +167,7 @@ cython_debug/
# Mac Os
.DS_Store

config.toml
config.toml

# Whobot's config: holds the Slack tokens.
whobot.toml
75 changes: 71 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,19 +104,86 @@ uv run fastapi run src/pqn_node/main.py

Browse protocols at http://127.0.0.1:8000/docs.

### Daily report
### Node host provisioning

Run or schedule the Slack health-report digest:
Two routes under `/system` operate on the host itself and need one-time setup on each Node. Both are used by remote operations tooling; a Node without them still runs every protocol, it just answers those two routes with an error.

**`GET /system/screenshot`** shells out to [`maim`](https://github.com/naelstrof/maim), which writes a PNG of the whole X root window to stdout (so a multi-monitor Node returns all its screens in one image):

```bash
uv run pqn-node daily-report run
uv run pqn-node daily-report schedule
sudo apt install maim
```

The API process must be started from inside the desktop session — KDE autostart does this — so that it inherits `DISPLAY`, `XAUTHORITY` and `XDG_RUNTIME_DIR`. Started from a bare SSH shell, capture fails with a 503 rather than returning a black frame.

**`POST /system/reboot`** runs `sudo systemctl reboot`, so the user running the API needs to do that without a password prompt:

```bash
echo "$USER ALL=(root) NOPASSWD: /usr/bin/systemctl reboot" | sudo tee /etc/sudoers.d/pqn-reboot
sudo chmod 440 /etc/sudoers.d/pqn-reboot
```

The endpoint returns before the machine goes down, so the caller gets a response and can poll until the Node answers again. Recovery is unattended: on boot the machine autologs in and KDE autostart brings the API, GUI and kiosk back up.

> [!WARNING]
> Neither route is authenticated, like every other Node API route — Nodes are expected to listen only on their VPN addresses, and membership of that network is the trust boundary. Any member of it can reboot any Node.

### Install the Web GUI

See [pqn-gui](https://github.com/PublicQuantumNetwork/pqn-gui) for install and start instructions.

## Whobot

Whobot is how you operate a Network from Slack. It is the second deployable in this repo
(`pqn_whobot`), and **one** instance serves **every** Node, talking to each over the Node API.
It does not run on a Node — put it anywhere that can reach them.

Type `/whobot` in Slack and pick from a menu:

- **Daily Digest** — every Node's hardware health *and* a real CHSH and Quantum Fortune run, in
one scheduled message. Also runnable on demand, and its schedule is changeable from the menu.
- **List Nodes / Node Info / Check one Node** — what is out there, and is it well.
- **Screenshot / Reboot** — see a Node's screen, or restart it (with a confirm step).
- **Change Game availability** — turn Games on and off without touching `config.toml`.
- **Run CHSH / Run Quantum Fortune** — a single measurement, by hand.

### Set up Whobot

**1. Create the Slack app** at <https://api.slack.com/apps> → *Create New App* → *From scratch*,
and name it Whobot. Then, in its settings:

| Page | Do this |
|---|---|
| **Socket Mode** | Toggle on, and generate an app-level token with `connections:write`. This is `slack_app_token` (`xapp-…`). |
| **OAuth & Permissions** | Bot token scopes `chat:write`, `commands`, `files:write`, `channels:read` — plus `groups:read` if the digest goes to a private channel. Install to the workspace and copy the bot token (`xoxb-…`) as `slack_bot_token`. |
| **Slash Commands** | Create `/whobot`. Leave the Request URL blank. |
| **Interactivity & Shortcuts** | Toggle on. Request URL blank here too. |

Socket Mode is why both URLs stay blank: Whobot dials **out** to Slack, so it needs no public
address, no certificate, and no inbound firewall rule. Watch the scope list — `channels:read` sits
next to `channels:history`, and the wrong one makes Whobot refuse to start.

Finally, invite the bot to the channel the digest should go to, and copy that channel's ID from
the bottom of its *About* tab.

**2. Configure and run.** `configs/whobot_example.toml` is a commented reference for every key.

```bash
cp configs/whobot_example.toml whobot.toml # fill in: both tokens, digest_channel, each Node's address
uv sync --extra whobot # the Slack transport; a Node itself does not need it
uv run whobot nodes # check: every Node listed, named, and reachable
uv run whobot serve # holds the Slack connection and fires the digest
```

Whobot reads `whobot.toml` from the directory it is started in, and never a Node's `config.toml` —
its config is the Network's, not a machine's. `whobot serve` checks the tokens and the digest
channel before it starts, so a bad token or a channel the bot is not in fails immediately rather
than at 07:00.

> [!NOTE]
> Whobot has no access control of its own: anyone who can see the bot can run any Action,
> including Reboot. The channel is the audit log.

## Acknowledgements

The Public Quantum Network is supported in part by NSF Quantum Leap Challenge Institute HQAN under Award No. 2016136, Illinois Computes, and by the DOE Grant No. 712869, "Advanced Quantum Networks for Science Discovery."
Expand Down
12 changes: 1 addition & 11 deletions configs/config_example.toml
Original file line number Diff line number Diff line change
Expand Up @@ -52,14 +52,4 @@ dark_count = 0
# RNG / Quantum Fortune settings
[rng_settings]
channels = [1, 2] # Timetagger channels to sample for singles parity
fortune_size = 8 # Number of parity measurements per fortune run

# Daily report settings (for automated Slack reporting of hardware + games)
[daily_report]
slack_webhook_url = "https://hooks.slack.com/services/YOUR/WEBHOOK/URL" # Get from https://api.slack.com/apps
api_url = "http://localhost:8000" # API endpoint (usually localhost if running on same machine)
timetagger_address = "127.0.0.1:8000" # TimeTagger address
follower_node_address = "192.168.1.100:9000" # Replace with actual follower node address
basis = [0, 22.5] # CHSH basis angles to use for daily measurements
overall_timeout_s = 1800 # Hard watchdog for the whole run (SIGALRM). Posts a Slack error and exits if exceeded.
per_game_timeout_s = 600 # Per-game HTTP timeout.
fortune_size = 8 # Number of parity measurements per fortune run
91 changes: 91 additions & 0 deletions configs/whobot_example.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
# Whobot configuration — the reference copy.
#
# MAKE SURE TO RENAME THIS FILE TO whobot.toml AND PLACE IT WHERE WHOBOT IS STARTED FROM
#
# Copy this to whobot.toml (gitignored) and fill it in. Whobot reads ./whobot.toml — the
# filename is fixed and the directory is wherever Whobot is started from, exactly as a Node
# reads its own ./config.toml. There is no path flag and no environment variable.
#
# This file holds the Slack tokens, so it is never committed. Whobot only reads it today;
# once it starts recording the schedule and each digest run, it will write it atomically
# (temp file + rename) so a crash can never leave it truncated.
#
# Whobot must NOT be pointed at a Node's config.toml.

# Slack credentials. Socket Mode needs both; Node-facing commands (`whobot nodes`) work
# without them. To create them, go to https://api.slack.com/apps, Create New App, From
# scratch, and name it Whobot. Then:
#
# Socket Mode toggle on, generate an app-level token with `connections:write`.
# That token is slack_app_token, and starts with "xapp-".
# OAuth & Permissions bot token scopes `chat:write`, `commands`, `files:write` (for the
# screenshot upload) and `channels:read` — NOT `channels:history`,
# which sits next to it and does not work: Whobot never reads
# messages, it checks that digest_channel exists and that it is in
# it. Add `groups:read` too if the digest goes to a private channel.
# Install to the workspace. That token is slack_bot_token, "xoxb-".
# Slash Commands create /whobot. No Request URL is needed under Socket Mode.
# Interactivity toggle on. Again no Request URL.
#
# Finally invite the bot to the channel the digest goes to, and put that channel's ID in
# digest_channel below — `whobot serve` refuses to start until both are true, since otherwise
# the first sign of trouble is a digest that silently never arrives.
slack_bot_token = "xoxb-..."
slack_app_token = "xapp-..."
digest_channel = "C0123456789" # channel ID the Daily Digest is posted to

# Daily Digest schedule, interpreted in schedule_timezone — never the host's local time,
# so moving the Whobot host doesn't move the digest. DST is handled by the zone.
# Note UFL Nodes are Eastern, so 07:00 Central is 08:00 local for them.
schedule_timezone = "America/Chicago"
schedule_hour = 7
schedule_minute = 0

# How the digest measures a Node. The address is resolved on the Node, so 127.0.0.1 means each
# Node's own host and one value serves the fleet; the port is the Node API's (9000 in
# production, 8000 from a dev checkout). basis is the CHSH measurement angles.
timetagger_address = "127.0.0.1:9000"
basis = [0.0, 22.5]

# How long one Game may take. Everything longer is derived from it: a Node's bound is its two
# Games plus the two calls around them, and the digest's is that once per Node in the registry.
# So adding a Node widens the digest's budget on its own, and no two settings can disagree
# about the same wait.
per_game_timeout_s = 600

# How long a mere "are you there?" call waits. Far shorter than the digest budget, so
# `whobot nodes` reports a dead address in seconds instead of appearing to hang.
reachability_timeout_s = 5

# Bound on one Node API call that does work — reading a config, writing availability, capturing
# a screenshot. Neither key above fits: 5s is for "are you there?", 600s is for a whole Game.
node_timeout_s = 30

# How long a rebooted Node has to answer again before Whobot reports it as still down. Raise it
# for a machine with a slow POST or one that fscks on boot; the Reboot Action's own budget is
# worked out from this, so it always has time to report what it found.
reboot_wait_s = 300

# Development only. Screenshot is the one Action that needs a Node in front of a real
# display — X11, KDE and `maim` — so on a laptop it can only ever fail. Point this at any
# image file and the Screenshot Action answers with it and never calls the Node. The report
# says as much, in a warning naming this setting, so nobody mistakes the picture for a Node.
# Leave it out in production.
# debug_screenshot_path = "/path/to/some-image.png"

# Whobot writes these two back itself after each digest run; leave them out of a fresh file.
# They must stay ABOVE the [[nodes]] tables below. A plain key written after a table belongs to
# that table, so moving these to the end of the file makes them fields of the last Node, and
# Whobot then refuses to start.
# last_run_at = 2026-01-01T07:00:00-06:00
# last_result = "ok"

# The Node Registry. Whobot knows about exactly these Nodes — adding one is an edit here,
# not a code change. Use each Node's address on the VPN; production Nodes listen on 9000.
# Node *names* are deliberately not listed: Whobot reads them from each Node's
# GET /node/config, so this file cannot drift out of date. An address is all an entry holds.
[[nodes]]
api_url = "http://xx.xx.xx.xx:9000"

[[nodes]]
api_url = "http://xx.xx.xx.xx:9000"
27 changes: 24 additions & 3 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,11 @@
requires = ["uv_build"]
build-backend = "uv_build"

[tool.uv.build-backend]
# Two deployables ship from this repo, so both modules must be named; the default is the
# one module matching the project name, which would silently omit pqn_whobot from a build.
module-name = ["pqn_node", "pqn_whobot"]


[project]
name = "pqn-node"
Expand All @@ -22,16 +27,31 @@ dependencies = [
"pydantic>=2.0",
"pydantic-settings>=2.10.1",
"pyserial>=3.5",
"tomli-w>=1.0.0",
"tomlkit>=0.13.0",
"typer>=0.15.1",
]

[project.optional-dependencies]
# Whobot's Chat Platform transport. Every Node ships Whobot's code but never runs it, so
# these stay out of the base dependencies and a Node deployment does not install them.
# Whobot's host installs them with `uv sync --extra whobot`; `whobot nodes` needs neither.
# aiohttp is explicit because slack-bolt's `[async]` extra was removed in 1.30, and async
# Socket Mode still needs it.
whobot = [
"slack-bolt>=1.18",
"aiohttp>=3.9",
]

[project.scripts]
pqn-node = "pqn_node.cli:app"
whobot = "pqn_whobot.cli:app"


[dependency-groups]
dev = ["hypothesis", "mypy", "coverage", "pytest-randomly", "ruff"]
# The whobot extra is pulled in here so the Slack tests run with a plain `uv run pytest`.
# It stays an extra rather than a dev dependency because a Whobot host needs it in
# production, where dev groups are not installed.
dev = ["hypothesis", "mypy", "coverage", "pytest-randomly", "ruff", "pqn-node[whobot]"]


[tool.mypy]
Expand Down Expand Up @@ -77,7 +97,8 @@ extend-ignore = [

[tool.ruff.lint.extend-per-file-ignores]
"tests/*" = [
"S101", # Assert
"S101", # Assert
"ARG002", # A test double exists to have a signature, not to use every argument in it.
]
# Run pytest lint rules only in test files.
"!tests/*" = ["PT"]
Expand Down
21 changes: 18 additions & 3 deletions src/pqn_node/api/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,18 +3,20 @@

from pqn_node.api.routes import chsh
from pqn_node.api.routes import coordination
from pqn_node.api.routes import debug
from pqn_node.api.routes import health
from pqn_node.api.routes import qkd
from pqn_node.api.routes import rng
from pqn_node.api.routes import serial
from pqn_node.api.routes import system
from pqn_node.api.routes import timetagger
from pqn_node.api.routes.health import get_effective_availability
from pqn_node.core.config import GamesAvailability
from pqn_node.core.config import settings
from pqn_node.core.config import update_config


class NodeConfig(BaseModel):
node_name: str
follower_node_address: str | None


Expand All @@ -25,15 +27,28 @@ class NodeConfig(BaseModel):
api_router.include_router(rng.router)
api_router.include_router(serial.router)
api_router.include_router(coordination.router)
api_router.include_router(debug.router)
api_router.include_router(health.router)
api_router.include_router(system.router)


@api_router.get("/games/availability", tags=["games"])
def get_availability() -> GamesAvailability:
return get_effective_availability()


@api_router.put("/games/availability", tags=["games"])
def set_availability(availability: GamesAvailability) -> GamesAvailability:
"""Set which games this Node offers, persistently and without a restart.

Writes ``config.toml`` (comments preserved) *and* applies the change to the
live settings object, so the response already reflects the new configuration
gated by the most recent hardware probe. A game the hardware can't support
stays unavailable no matter what is set here.
"""
update_config({f"games_availability.{game}": value for game, value in availability.model_dump().items()})
return get_effective_availability()


@api_router.get("/node/config", tags=["node"])
def get_node_config() -> NodeConfig:
return NodeConfig(follower_node_address=settings.follower_node_address)
return NodeConfig(node_name=settings.node_name, follower_node_address=settings.follower_node_address)
18 changes: 0 additions & 18 deletions src/pqn_node/api/routes/debug.py

This file was deleted.

Loading