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 |
|
stdlib, |
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 |
|
model, |
The model’s side effects on the bench: creating environments ( |
Presenter |
|
model, services |
|
View |
|
presenter, |
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 |
|
presenter |
|
Host |
|
stdlib |
The only module with platform branches — see Cross-platform development. |
Re-exec |
|
stdlib |
How |
Client |
|
|
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 |
|---|---|
|
The Plesty share — the lab’s shared disk as this machine mounts it ( |
|
The bench’s folder in the share ( |
|
Its default folder there: the fleet’s |
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 |
|---|---|
|
|
|
The directory layout above. |
|
What is installed: |
|
|
|
|
|
|
|
|
|
The device’s |
|
The removable parts of a home, their sizes and what each one costs. |
|
|
|
|
|
The Traffic tab’s rows, parsed back out of a device’s log. |
The services
Service |
Does |
|---|---|
|
Creates environments with |
|
The detached launcher every server and job goes through. See Process management. |
|
Runs a command as a job: launches, then supplies |
|
Starts/stops/probes device servers and keeps their |
|
Fetches and searches the Hub’s module list (stdlib HTTP, injectable fetcher); |
|
Updating the application itself: what is installed and how, the newest release on PyPI, the start-up check ( |
|
Finds |
|
Serves |
|
|
|
|
|
An experiment’s run directory: create, configure, read state, ask it to pause or stop. See Experiment runs. |
|
Compiles a run directory into its self-contained |
|
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-runfor 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_instrumentsays whether the server has to be stopped.JobRunner(service) — runs that command as a job of kindfield-test, in the checkout, with the instance’senvfrom 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.