Architecture

plesty-server is built as model – services – presenter – view, with one extra module, host, for everything that depends on the operating system. The split answers one question for each piece of code: does it need the OS, the screen, or neither?

Layer

Package

Depends on

Knows about

Model

plesty.server.model

stdlib, yaml

Data and rules only: the fleet file, device specs, installed versions, job and process records, field-test requests and reports. Pure Python, no subprocesses, no Qt. Fully testable in memory.

Services

plesty.server.services

model, host, uv/git/zmq

The model’s side effects on the bench: creating environments (Installer), running commands as jobs (JobRunner), launching and stopping device servers (Supervisor). Take model objects in, hand model objects back.

Presenter

plesty.server.presenter

model, services

Bench — choreography: install → start, stop → field test → relaunch, fleet up. Driven by both the CLI and the GUI.

View

plesty.server.view

presenter, plesty.lib.ui.theme, PySide6

The Qt console: plesty-lib’s palette and fonts, our own layout. Renders state, emits intents, never touches a service — see The console’s design.

CLI

plesty.server.cli

presenter

plesty-server … commands — see plesty-server.

Host

plesty.server.host

stdlib

The only module with platform branches — see Cross-platform development.

Re-exec

plesty.server.reexec

stdlib

How plesty-server starts another copy of itself (python -m plesty.server with its own interpreter); see Packaging.

Client

plesty.bench

pyzmq, click

The remote client — a separate, published package; see The agent and the client.

Dependencies point downwards only. Services never import each other’s internals; a presenter composes them.

The home directory

Everything plesty-server writes lives under one directory, ~/.plesty/server by default (PLESTY_SERVER_HOME, home: in the fleet file or --home override it). Home in the model is the one place that knows the layout:

~/.plesty/server/
├── fleets/                     the bench's fleets, one file each
│   └── default.yaml            the one a new bench starts on, written empty
├── fleet.yaml                  a bench's fleet from before fleets/ (cwd first)
├── packages/                   installed device modules, one env per version
│   └── plesty-pm100d/
│       ├── 0.2.1/              uv venv (relocatable)
│       ├── 0.2.1.json          InstalledVersion record
│       ├── 0.3.0/
│       └── 0.3.0.json
├── repos/                      git checkouts, one per (package, ref)
│   └── plesty-pm100d/exp/      checkout; its .venv is the environment
├── state/                      one ProcessRecord per started server
│   ├── pm100d.json
│   └── pm100d.exit             exit code, written when it ends
├── run/pm100d/                 the server's working directory — and its .env
├── logs/
│   ├── pm100d.log              server stdout+stderr, appended per start
│   └── jobs/<job-id>.log       one log per job
├── jobs/
│   ├── <job-id>.json           Job record
│   └── <job-id>.exit
├── tools/                      git fetched for a Windows bench that had none
├── runs/<experiment>/          experiment runs, when the module declares no shared root
├── data/                       where devices write while the bench has no share (#43)
├── demo/<experiment>/          the welcome demo's runs, which its spectrometer replays
├── agent.yaml                  the agent's settings, this bench's role, share and folder
├── peers.yaml                  a master's benches, with their tokens
├── last-fleet                  the fleet the console opened last
├── last-operator.txt           who created the last run, proposed for the next
├── update-check.json           when the console last asked PyPI for a newer bench
└── welcome-seen                the welcome has opened once by itself

An experiment’s setup.yaml is read as a fleet too — fleet up -f setup.yaml — but it is the experiment’s file, committed with it, and nothing under the home: the bench never writes it (Fleet.is_setup, Ports).

Fleets

A bench has fleets — any number of files in fleets/ — and one current fleet, the one the CLI’s -f, the console’s Open fleet or last-fleet names. A device name is declared in one fleet only; the ports of all of them are allocated together, since their devices run side by side on one machine. The console shows every fleet at once (View ▸ Fleet narrows it), so a device the agent declares into any of them is in sight.

Two processes write the same fleet files — the console and the agent — so a write never comes from what was read earlier: Bench._edit_fleet re-reads the file, applies the one change and writes it back, and the console re-reads a file whose modification time moved (#18).

Where a device writes

Each device the bench starts is told where it may write, by environment (AgentSettings.device_data_env):

Variable

Is

PLESTY_DATA

The Plesty share — the lab’s shared disk as this machine mounts it (Z:\plesty). A device reports its files relative to it, so any machine joins them to its own mount.

DEVICE_DATA_ROOT

The bench’s folder in the share (<share>/platform-a): the only place the device may write.

DEVICE_DATA_PATH

Its default folder there: the fleet’s data_dir, or tmp/<device>.

Without a share the bench uses a folder of its own, <home>/data, for the first two and leaves PLESTY_DATA unset (#43) — a device that must have a data path refused to start on a bench alone. The device’s .env and the fleet’s env: still override them, in that order. A run the bench starts gets PLESTY_DATA as well when there is a share; plesty-lib’s route() then sends each device it names to the run’s own raw/<device> folder under that device’s bench folder (Experiment runs).

The user-facing version of this tree is Files and settings.

Every record is a small JSON file. That is deliberate: several plesty-server processes look at the same bench at once — a CLI call, the agent, the GUI — and a file is the simplest thing all of them can read, and the only one that survives the process that wrote it.

The model

Module

Holds

fleet, device, ports

Fleet and DeviceSpec — what a fleet file declares: package, version or git+ref, args, env, port, data_dir, autostart; Fleet.is_setup for an experiment’s setup, which is only read. Port rules and allocation (Ports).

home

The directory layout above.

device_manager

What is installed: InstalledVersion (package, version, source pypi/git, ref, commit, env path) and DeviceManager — the inventory. resolve(spec) answers “which installed version serves this spec, or must we install?”.

job

Job records and JobStore — create/finish/list records, read logs by byte offset. Knows nothing about processes; refresh() takes is the pid alive and what exit code was left as callables.

process

ProcessRecord (what a server was started with) and ProcessStatus (stopped / starting / running / exited).

field_test

FieldTestRun (tier, address, gates → argv) and FieldTestReport (the published field-test.json, summarised).

catalogue

CatalogueEntry — one module the Hub lists (/api/modules/), its standard and check/field-test status.

envfile

The device’s .env (<home>/run/<name>/.env): parse and render.

purge

The removable parts of a home, their sizes and what each one costs.

experiment

ExperimentSpec — what fleet.yaml declares under experiments: — and ExperimentManifest, what a module’s --manifest prints.

agent, peer

agent.yaml (endpoint, token, workers, role, share and folder, the start-up update check) and peers.yaml (the benches a master drives). AgentSettings.device_data_env is where a device writes — above.

traffic

The Traffic tab’s rows, parsed back out of a device’s log.

The services

Service

Does

Installer

Creates environments with uv (and git for checkouts) and records them in the DeviceManager. See The installer.

launch

The detached launcher every server and job goes through. See Process management.

JobRunner

Runs a command as a job: launches, then supplies JobStore with the two facts it lacks (pid alive? exit code?). Install, update, field test and plesty check are all jobs.

Supervisor

Starts/stops/probes device servers and keeps their ProcessRecords. Liveness is a protocol probe (describe over ZMQ), not a pid check.

Catalogue

Fetches and searches the Hub’s module list (stdlib HTTP, injectable fetcher); pypi_release asks PyPI what the registry does not know, pypi_releases and remote_tags (git ls-remote) list every release and tag a version drop-down offers.

updates

Updating the application itself: what is installed and how, the newest release on PyPI, the start-up check (update-check.json, once a day), and the helper that installs a newer release from outside the running environment — see Packaging.

gitbin

Finds git — PATH, the usual Windows installs, <home>/tools/git — and installs a portable one for a Windows bench that has none.

Agent

Serves plesty.bench clients: runs CLI argument lists in-process and returns the output; fetch for reports; device for a relayed request.

control

DeviceLink — one connection to a device server, for a control panel.

nodes

Nodes, NodeLink — a master’s links to other benches’ agents, and their devices as rows. See Several benches.

experiments

An experiment’s run directory: create, configure, read state, ask it to pause or stop. See Experiment runs.

run_report

Compiles a run directory into its self-contained report.html.

problem_report

What a bench says about itself when something goes wrong — its version, what it holds and the log tails, redacted — as text the operator files themselves.

How a request flows

“Start pm100d” from the CLI or the GUI:

presenter
  │ fleet = load_fleet(...)                     model
  │ spec  = fleet["pm100d"]                     model
  │ inst  = manager.resolve(spec)               model   ─ None? → installer.install(spec) (a job)
  │ py    = installer.python(inst)              service
  │ status = supervisor.start(spec, py)         service ─ launch() → ProcessRecord → probe until ready
  └ view.show(status)                           view

Nothing in that chain imports Qt until the last line, and nothing before the service line touches the operating system.

Long work runs as a job of plesty-server itself

Bench.install(), update() and field_test() return a Job at once. The job’s command is python -m plesty.server --home <home> -f <fleet> _job install <name> — the same program, a hidden command group, run detached through the launcher like every other job.

So the install’s uv output lands in logs/jobs/<id>.log, the CLI streams it (plesty-server install pm100d follows the log until the exit code), the GUI tails the same file, and a closed terminal does not kill a half-done install. The foreground twins (install_now, update_now, field_test_now) are what the _job commands call.

Field tests

A field test is a job with choreography around it, because an instrument has one owner: the server must be out of the way for the host tier, and back afterwards exactly as it was.

run = FieldTestRun(tier="host", address=None, gates=("connect", "drain"))
inst = manager.resolve(spec)                     # must be a git install
record = supervisor.record(name)
if run.needs_instrument and not run.keep_server and supervisor.alive(name):
    supervisor.stop(name)                        # the instrument is single-owner
if run.tier == "client" and not supervisor.alive(name):
    supervisor.start(spec, installer.python(inst))
job = jobs.start(name, JOB_KIND, run.command(installer.python(inst), checkout),
                 cwd=checkout, env=spec.env, detail=run.to_dict())
job = jobs.wait(job.id)                          # or stream jobs.store.log(...)
if record is not None:
    supervisor.relaunch(record)                  # back exactly as it was
reports = find_reports(checkout, since_utc=job.started_utc)

relaunch(record) is why ProcessRecord keeps the interpreter, module, args, env and port: the server comes back with the same arguments, on the same port a client connected to before the run.

The pieces:

  • FieldTestRun (model) — the request. argv() builds the script’s arguments (the host tier gets --report reports/field-test-run for its internal pair); command(python, checkout) builds the full command line and fails with the “install from git and scaffold” hint when the script is missing; needs_instrument says whether the server has to be stopped.

  • JobRunner (service) — runs that command as a job of kind field-test, in the checkout, with the instance’s env from the fleet, so the address and the report archive come from the same place as for the server.

  • FieldTestReport (model) — reads what was published: ok, passed / failed / skipped, per-gate results, findings by severity. find_reports(checkout, since=job.started_utc) lists what this run produced, rather than every report the checkout has ever held.