# 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](console.md). | | CLI | `plesty.server.cli` | presenter | `plesty-server …` commands — see [`plesty-server`](../cli/plesty-server.md). | | Host | `plesty.server.host` | stdlib | The **only** module with platform branches — see [Cross-platform development](cross_platform.md). | | Re-exec | `plesty.server.reexec` | stdlib | How `plesty-server` starts another copy of itself (`python -m plesty.server` with its own interpreter); see [Packaging](packaging.md). | | Client | `plesty.bench` | `pyzmq`, `click` | The remote client — a separate, published package; see [The agent and the client](agent.md). | 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: ```text ~/.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/.log one log per job ├── jobs/ │ ├── .json Job record │ └── .exit ├── tools/ git fetched for a Windows bench that had none ├── runs// experiment runs, when the module declares no shared root ├── data/ where devices write while the bench has no share (#43) ├── demo// 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](ports.md#when-the-port-is-taken)). ## 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 (`/platform-a`): the only place the device may write. | | `DEVICE_DATA_PATH` | Its default folder there: the fleet's `data_dir`, or `tmp/`. | Without a share the bench uses a folder of its own, `/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/` folder under that device's bench folder ([Experiment runs](experiments.md)). The user-facing version of this tree is [Files and settings](../cli/files.md). 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](ports.md)). | | `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` (`/run//.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](installer.md). | | `launch` | The detached launcher every server and job goes through. See [Process management](processes.md). | | `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 `ProcessRecord`s. 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](packaging.md). | | `gitbin` | Finds `git` — `PATH`, the usual Windows installs, `/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](../usage/remote.md#driving-other-benches-from-this-console). | | `experiments` | An experiment's run directory: create, configure, read state, ask it to pause or stop. See [Experiment runs](experiments.md). | | `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: ```text 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 -f _job install `` — 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/.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)= ## 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. ```text 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.