implementation: current-as-of-2026-09-14
Architecture
server-watcher (sw) manages the user's server infrastructure from the one
source of truth the user already maintains: ~/.ssh/config. This document is
the architecture index; per-area contracts land as follow-up documents and
must be linked from here.
Single workspace, eight crates
One Cargo workspace, one version of every dependency, one set of gates. The
split follows a single rule: pure vocabulary in sw-core, one crate per
runtime concern, nothing shared by accident.
graph TD
cli[sw-cli<br/>`sw` binary: serve / ui / mcp / sync / token]
api[sw-api<br/>axum: REST + WS terminal/events + auth + embedded UI]
mon[sw-monitor<br/>sync service + scheduler + retention]
mcp[sw-mcp<br/>stdio JSON-RPC MCP server]
ssh[sw-ssh<br/>system ssh: executor / probe / metrics / PTY]
store[sw-store<br/>SQLite WAL + migrations + repositories]
core[sw-core<br/>domain types + DTOs + ssh-config parser + error]
ui[sw-ui<br/>Leptos CSR, built by trunk into dist/]
cli --> api
cli --> mon
cli --> mcp
api --> mon
api --> ssh
api --> store
api -.embeds.-> ui
mcp --> store
mcp --> ssh
mon --> ssh
mon --> store
ssh --> core
store --> core
api --> core
mcp --> core
Layering rules:
sw-coredepends on nothing internal and no async runtime; it must stay compilable for wasm so the UI can reuse the DTOs and the ssh-config parser.sw-storeis the only crate that talks SQL; repositories speak sw-core types only.sw-sshis the only crate that spawnssshor opens sockets.sw-uiis a leaf: nothing in Rust depends on it — its build output (crates/sw-ui/dist) is the artifact, embedded bysw-api.
Run modes
The single sw binary exposes:
| Mode | What runs | Phase |
|---|---|---|
sw serve | axum API + embedded dashboard + the sw-monitor loops (sync, scheduler, retention) | 1–2 |
sw ui | the desktop shell (default when no subcommand is given): everything serve runs, plus a Tauri webview window and a status-item menu | 6 |
sw mcp | stdio JSON-RPC MCP server over the same SQLite file | 5 |
sw sync [--dry-run] | one-shot ~/.ssh/config sync into the store | 2 |
sw token | print a generated bearer token | 1 |
sw net | check whether THIS machine can reach the network | — |
sw backup / sw restore | snapshot the store and config into one archive, and put it back | — |
sw mcp is a separate process by design: an MCP client owns the process's
stdio, so the server cannot share a process with the HTTP server. Both
processes use the same SQLite database; WAL mode is what makes that
concurrent access safe.
System ssh reuse
The fleet is reached through the system ssh binary, never through an
embedded SSH library. Rationale: the user's ~/.ssh/config (aliases,
ProxyJump chains, Include files), known_hosts, agent and keys keep
working unchanged — there is no second source of truth for credentials, no
key material in the tool's own config, and no per-host connection setup to
maintain. The costs are explicit: process spawn per operation, output is
text to be parsed, and remote-side behavior depends on the installed ssh.
Both are acceptable at this scale (tens of hosts, minutes-scale polling).
Write path: config editing, keys, audit, purge
Phase 6 made ~/.ssh/config bidirectional: the dashboard (and the MCP
tools) can add, update and delete host stanzas through a stanza-splicing
editor that preserves every byte it does not replace. The same round added
SSH key management (scan / generate / delete / deploy), an append-only
audit log for every mutation, and a hard-purge path for stale hosts. The
full contracts — concurrency model, backup policy, deploy idempotency,
purge rules — live in 003-write-and-audit.md.
Alerting — what the collected metrics are finally for — has its own
contracts in 004-alerting.md, and the BYOK assistant,
which turns the same data into answers and answers into proposed
commands, in 005-assistant.md.
Three cross-cutting invariants are architectural, not per-feature:
- One editor — all config writes go through
sw-core's pureedit_config(String → String, no fs, wasm-compatible);sw-monitor'sConfigWriterowns the file lifecycle: read → edit → rollingconfig.sw.bakbackup → tmp(0600)+rename atomic write, all under a cross-processFile::lockon<config>.sw.locksosw serveandsw mcpcannot interleave read-modify-write cycles. - Every mutation is audited — config edits, key operations, host
purges, alert and assistant settings, and every command a fan-out runs
insert a row tagged with the caller's source (
api/mcp); the dashboard's/auditpage is a plain reader over it. - The catalog holds servers only —
~/.ssh/configentries that exist to pick an identity for a git remote (Host github-as-…) are filtered at the single sync read path, so the API, the CLI and the MCP server import the same fleet. Rows imported before the rule are deleted rather than flagged stale: an authentication alias is not a server that went away.
Localhost trust model
Two layers, no user accounts:
- Bind guard —
sw servebinds loopback only and refuses any other bind target. The API is local by construction. - Bearer token — every non-asset request must present the token
sw tokenprints (alsosw mcp, via environment). This keeps other local processes and drive-by web pages from driving the API, reading fleet data, or opening terminals.
The WebSocket terminal route carries the same token requirement; terminal
input is written to the PTY verbatim — the remote shell is the user's own.
The event stream route (GET /api/v1/events) authenticates the same way
(bearer header, sw_token cookie, or ?token=, plus the host guard):
authenticated BEFORE the upgrade, so a rejected handshake is a plain 401/403,
never a dropped socket.
Storage
One SQLite database in WAL mode, embedded migrations compiled in via
sqlx::migrate!, all queries runtime-checked (query/query_as functions,
no query! macros — nothing reads DATABASE_URL at compile time).
Aggregates: hosts (from sync), status history, metrics samples, exec
records, sync runs, the append-only audit_log (migration 0003) that
records every config/key/purge mutation with its source (api / mcp),
the alerting trio (migration 0004): thresholds, live alert state and
transition history; and the assistant's own settings (0008/0009), which
hold the operator's LLM credentials.
Retention (sw-monitor) prunes and downsamples so the file stays bounded on
an always-on machine. Both pool connections enable foreign_keys(true),
which is what makes ON DELETE CASCADE real: purging a host row drops its
status/metrics/exec history with it.
Backups are taken through SQLite, not the filesystem: sw backup asks the
store for a VACUUM INTO snapshot, so a copy taken while the server is
running is complete rather than a data.db whose recent writes are still
in a -wal sidecar. The archive also carries config.toml — the API
token and the assistant key live there — so it is written 0600. sw restore moves the files it replaces aside instead of deleting them, and
refuses an archive without a matching manifest.
Frontend and desktop shell
- UI — Leptos CSR built by trunk
0.21.14, pinned. Trunk0.22is a beta whose MSRV (1.96.1) is above this workspace's pinned Rust (1.93.1) — that combination cannot compile, which is exactly why the pin is explicit here and in the Phase 3 plan. Output lands incrates/sw-ui/dist, embedded bysw-apiwith rust-embed (mime-guesson for content types). The dist output is committed (CI never runs trunk): rebuild withtrunk build --releaseincrates/sw-ui, commit the hashed assets, and rebuild so rust-embed picks them up. The wasm entry is declared inindex.htmlas<link data-trunk rel="rust" href="Cargo.toml" />— a<script data-trunk src="*.rs">marker would take the script-asset pipeline and copy the Rust source verbatim. - Live updates — the monitor and sync publish
FleetEvents (status flips only, metrics samples, applied syncs) on atokio::sync::broadcastchannel created insw-cliand shared with the API state;sw-apiforwards them to subscribers of/api/v1/eventsas TEXT frames, and the dashboard bumps its poll tick on every frame, so live data lands without waiting for the next 10-second poll (the poll stays as the fallback; the client reconnects with capped exponential backoff).sw-monitorholds only anOption<Sender>— it never depends onsw-api, and callers that take no channel (MCP, tests) are unaffected. - Terminal — the in-browser terminal attaches to a persistent tmux
session on the remote host (
tmux new-session -A -s sw), guarded by acommand -v tmuxcheck so hosts without it still get a login shell. A build survives a closed tab, and reopening the terminal walks back into the same session rather than starting beside it. - Desktop shell —
sw uiruns the same axum server inside a Tauri v2 shell. Thread topology is fixed by AppKit: the Tauri event loop owns the main thread (which is whymaininsw-cliis a plainfn, not#[tokio::main]), while the store, monitor scheduler and axum run on a dedicated tokio runtime created insrc/ui.rs. Startup is ordered so atauri-plugin-single-instanceduplicate exits inside plugin init (never touching the shared port) andsetuponly builds the webview window after the listener is bound — the dashboard URL (http://127.0.0.1:7420/#token=…, same fragment flow as the browser) cannot 404 on first paint. Closing the window hides it (the status-item menu — Open Dashboard / Quit — is the only real exit), and quitting drops the runtime, which cancels the monitor loops (kill_on_dropssh children) and closes live PTY masters (SIGHUP tossh -tt). The shell's static assets are committed:crates/sw-cli/tauri.conf.json(no static window — the window is built at runtime with the token-bearing URL;frontendDistpoints at the committedgui-stub/placeholder),crates/sw-cli/icons/(RGBA PNGs;generate_context!reads them at compile time), andbuild.rscallstauri_build::build(). Thesw-mcpbinary lives in the same crate and links tauri but never initializes it.
Phase plan (summary)
- ssh-config parser → sw-store schema → API end-to-end (
serve,token). - sw-monitor: sync service, probe/metrics scheduler, retention + history.
- sw-ui: Leptos CSR dashboard via trunk.
- Terminal (PTY → WebSocket) and remote exec.
- sw-mcp: stdio MCP server.
- Tauri shell and packaging.
Round 2 (2026-09, complete): config write-back with diff preview and
audit → key management (scan/generate/delete/deploy) → key & audit pages
in the UI → the config/key/audit tools exposed over MCP → stale-host
purge → alerting on host-down and resource thresholds, with a
connectivity gate → a BYOK assistant beside the terminal and on the
dashboard → 22 MCP tools, destructive ones dry_run-defaulted.
Contracts: 003-write-and-audit.md,
004-alerting.md,
005-assistant.md.