A generic salmon server: `serve` behind an API, with clients that show the DAG
On Fri, 25 Sep 2026, by @lucasdicioccio, 3538 words, 0 code snippets, 0 links, 0images.
Generated from specs/generic-server.md — the repository is the canonical source, and may be ahead of this page.
A generic salmon server: serve behind an API, with clients that show the DAG
Status: milestones 1 to 8 below are implemented (--json via
Salmon.Reporter.Tagged; run serve --listen via Salmon.Actions.Serve.Socket;
run serve --http via Salmon.Actions.Serve.Http with /dag, /status,
/history, /help/seed, POST /command[?async]; GET /events via
Salmon.Actions.Serve.Events and --events-ring; mode on status//dag;
salmon-tui over Salmon.Client.Http/Salmon.Client.Model; the web UI under
salmon-ops/ui/ served at GET /; and --http-tcp with --tls-cert,
--tls-key, --token-file), each with its deviations recorded in place in
the milestone list. Milestone 8’s closing “Not done: the clients” has since
been done: salmon-tui https://HOST:PORT --token-file FILE [--cacert FILE],
and a browser signs in at /auth for a session cookie (--session-lifetime,
--session-idle, sign-out at /auth/logout). Still open: the web UI’s
“Not yet” items under milestone 7 (a Conflicting pair side by side, batch
and RemoteOp expansion, history as a timeline), client certificates and
a read-only token. Kept as the design record. Companion to specs/pull-mode.md (which is about a host
fetching its declarations); this one is about talking to a running
serve — a web UI, a terminal UI, tooling — and about finally showing the
DAG as what it is while it is being converged and tended.
Problem
run serve’s entire surface is one Handle in and a text Reporter out
(salmon-ops/src/Salmon/Actions/Serve.hs, serveWith; the reader thread
hGetLines into a TChan, loop reads it). Everything else follows from
that:
- One operator, one terminal. No second client can attach, no tool can drive a running supervisor, and there is no auth because there is nothing to authenticate to.
- A remote
servecannot be addressed.Self.callSelfonly ever runs a one-shotrun upover ssh.ssh host bin run serveworks, but the only thing that can then speak to it is that ssh session’s stdin. - The DAG is invisible while it runs.
run tree/run dagprint a static picture before anything happens (Help.printDagTree,Dot.printDagCograph);statusprints a flat list after. The one thing salmon is best at — a graph with per-node state, failure containment andConflicting/Blockeddistinctions — is never rendered as a graph while a pass or the tending loop is acting on it.
The author of the line protocol is the first to lament it. This sketch is the replacement.
What already exists as a value
None of this needs a new engine; it needs a transport and a read model over values the loop already maintains.
World(Serve.hs): the ledger, the magma (one representative perRef), and aNodeStateper node —nodeShorthand,nodeHelp,nodeDirection,nodeConvergence(Pending/Stale/Converged/Errored/Blocked) and, since (R3),nodeStatus: the node’s own lastCheckResult, when it last did anything observable, and its output ring.Dag(Salmon.Op.Dag):dagNodes,dagDependencies,dagDependants,dagOrder,dagConflicts— already the collapsed, rewrite-applied structurerun tree/run dagprint, withmembersOffor a rewrite-introduced node standing in for declared ones.- Three report streams, all plain sum types:
Serve.Report(Started,BadCommand,Loading, … the loop’s own events),UpDown.Report(Skip/Eval/Done/Failed/Blocked/Conflicting/Instructed/…) andUpkeep.Report(Acted,Upkeep/Downkeepstate changes,NextLook,Wedged/Unwedged, demotions).Reporteris contravariant and already hasencodeJSON; what is missing isToJSONinstances on these types (most carry anAct ext, which needs a serializable projection — shorthand, ref, help, notes — not the closures). - Selectors:
parseSelection/resolveWorldSelectorsresolve--select/--excludepath globs and#refprefixes against the live world;statusprints the paths a pattern can match. RemoteOp(CommandLine.hs): the dynamic that letsrun dagdraw a remote call’s subgraph locally (injectRemoteSubgraphs). A live view can use the same thing to expand a remote node.- Mailbox instructions (
Salmon.Op.Mailbox.Instruction:Force/Satisfy/Recheck/Pause/Resume) and theforce/recheck/pause/resumecommands that queue them.
Design
The server is generic because the protocol never interprets a seed
Every salmon binary is a different program (ParseRecord seed, its own
Configure, its own Track' directive), so a server bolted onto serve
has to be binary-agnostic to be worth writing once. It is, as long as the
protocol carries seeds as opaque word lists — exactly what the line
parser already takes after up/only/down — and speaks about nodes only
in terms of Ref, path, shorthand, help, notes and state. A client that
speaks that drives any salmon binary; it never knows what pgpair primary=db1 means.
The one place this leaks: composing a seed. The server exposes each binary’s
own --help text for config (it is optparse-generic’s, and already
exists), and optionally a JSON schema derived from the ParseRecord
instance, so a UI can offer a form instead of a free-text line. That is the
only non-generic surface and it is bounded.
Three surfaces, one inbox
The loop stays as it is: a single consumer reading lines off one TChan,
calling stopTending before each. The server is another producer into
that channel (the same generalization specs/pull-mode.md milestone 1
asks for), plus a reader of World and the report streams. Concretely:
- Commands —
POST /commandwith{"line": "up pgpair primary=db1"}or a structured form{"verb":"up","seed":[...]}(both accepted, the second rendered to the first); the server writes the line to the inbox. Both a sync and an async mode: sync (the default) blocks and returns the reports that command produced, since the loop already knows when a command’s reports end (stepreturns) — whatcurland a CI step want;?asyncreturns immediately with the sequence number at which the command was enqueued, and the client reads its reports off/eventsfrom there — what a UI wants. The existing grammar is the API, unchanged.stdinkeeps working alongside. - Reads —
GET /dagreturns the computedDagas JSON: one object perRefwithref,short,shorthand,help,notes,direction,convergence,check(lastCheckResult),output(tail),paths(the declared positions a selector can match),members(for a rewrite-introduced node),remote(a nestedDag, fromRemoteOp), anddependencies/dependantsas ref lists. It is built by reading the magma —worldDagfromworldMagmaplus the ledger’s precedence, exactly as a convergence pass builds its own walkable structure — so it exists the moment anything has been declared, whether or not a pass has run yet (autoconverge offwith declarations recorded is the ordinary case: every nodePending, edges in place). PlusGET /status,GET /history,GET /help/seed. Reads never touch the inbox and never stop tending: they read theIORef Worldand the tending snapshot. - Events —
GET /events, server-sent events (one connection, text, proxies andcurlunderstand it; WebSocket adds nothing here): one stream, everyServe.Report,UpDown.ReportandUpkeep.Reportas JSON, each tagged with its kind and theRefit concerns and a monotonic sequence number so a client that reconnects can ask?since=N. The server is one moreReporter— the codebase’s contravariant reporter type (Salmon.Reporter; the tracer word is taken by the ops themselves) — composed withreportBothbeside the text reporter the binary already has,contramapped from each report type into one tagged sum. Nothing in the loop learns that a server exists; a client filters. This is the stream a live DAG view animates from.
Transport, in order: a unix socket carrying the line protocol first —
serveWith already takes any Handle, so this is nearly free, it makes a
remote serve addressable through ssh -L, and it lets a second client
attach today; then HTTP on that socket or a TCP port with the three
surfaces above; TLS and auth when it listens on anything but localhost or a
unix socket (see below).
Reads and the tending loop
stopTending before every command exists because a command is about to
act. A read is not, so reads bypass the inbox and never stand the machines
down — which is exactly the property the loop’s (R3) snapshot design was
built for: nodeStatus is snapshotted by stopTending and re-snapshotted
on the next, so a read sees a snapshot that is at most one command old. For
a live view that is not enough; the event stream is what carries the
between-commands changes (Upkeep.Reports are emitted by the running
machines, not by the loop). So: GET /dag for the picture, /events for
the motion, and a client rebuilds the current state as dag ⊕ events since the dag's sequence number. The server records the sequence number at which
each snapshot was taken and returns it with the snapshot, so the client
knows where to resume.
Serialization: what an Act ext becomes
Act ext holds closures (up/check/down/managed) and Dynamics. The
wire projection is exactly the fields Dag.sameRepresentative compares —
shorthand, help, notes, the rendering of dynamics (with
Supervision by value, as Dag.showDynamic already does) — plus the Ref.
That is not a coincidence: it is the set of things that are comparable,
and a UI that shows exactly those is showing what serve itself can see.
RemoteOp is the one dynamic that gets special treatment (expanded to a
nested Dag), same as run dag does today.
Modes, so a client knows which guarantees apply
status and /dag report the loop’s mode: replay (a piped script or
startup replay — every line queued, never supervised, deterministic),
interactive (tending between commands), and, once pull mode exists,
following (documents arriving on the scheduler). A web client showing a
node as “supervised” while the loop is replaying a script would be lying.
Clients
- Web UI. Renders
/dagas a graph (a layered DAG layout —dagreor ELK — not force-directed; dependency direction is the information), colours nodes byconvergence, overlayscheck, animatesEval/Done/Failed/Blockedas they arrive on/events, shows aConflictingpair side by side, collapses a rewrite-introduced batch to its members on click, expands aRemoteOpnode into its subgraph, and turns a click on a node intoforce/recheck/pause/resumewith the#refselector the server already accepts.historyis a timeline. Aconfigform built from/help/seedcomposes anup.Dotoutput stays as the export. - Terminal UI. Same API, same read model, for the box with no browser:
a tree view (the
run treeshape) with live state, a report pane tailing/events, and a command line that is the existing grammar. This should be a client of the socket, not a mode ofserve, so it works against a remote host overssh -Lunchanged. - Tooling.
curl,jq, a CI step asserting every node isConvergedbefore proceeding; asalmon-fleet statusthat folds/dagfrom N hosts (or from the pull-mode status sink, which should emit the same JSON).
The web UI is a separate package (salmon-web, PureScript is already in
the tree via Spago/purescript-bridge; the bridge can generate the
client types from the Haskell ones, which is exactly what it is for). The
server itself lives in salmon-ops next to Serve.hs if it can stay light
(a small WAI app), else in its own package so salmon-ops does not grow a
warp dependency for every binary that never listens.
Security
A unix socket inherits filesystem permissions and needs nothing else. For
TCP: TLS (the Certificates nodes can mint the cert; this is what they are
for), and a bearer token or client certificate — the tree already has JWT
signing (SreBox.JWTSigning). Commands and reads are the same privilege in
v1 (an up is as sensitive as reading the output ring of a node holding a
pgbouncer userlist); a read-only token is an obvious v2. Default to
localhost or a unix socket; never listen on 0.0.0.0 without TLS and auth
configured — a salmon server is root on the box, one up away.
notes, help, output rings and report text are public — the
convention already in force, since all of them end up in logs (this is why
Filesystem.checkFileContents names files and never quotes them, and why
filecontents puts a fingerprint in notes, never content). The server
inherits that convention rather than adding a redaction layer: it ships
exactly what the text reporter would print. A real story for sensitive data
(what a node may put where, and what a reporter may emit) is needed and is
another spec, not this one; until it exists, node authors should assume
anything they write into a report or an Extension text field is readable
by anyone who can read the logs.
Interaction with pull mode
The two sketches meet at the inbox and at the status JSON. Pull mode adds a
producer (the scheduler) and a consumer of /dag-shaped JSON (the status
sink). A fleet view is then either a salmon-fleet that folds sink objects
(no server needed on any host) or a web UI that connects to N hosts’
/events (a server on every host). Both should work; the former is the
cheap one and lands first.
Non-goals (v1)
- Multi-user access control, audit trails, read-only roles.
- A server that manages other hosts (that is pull mode plus a fleet
fold; this server speaks for one
World). - Editing seeds or directives in the UI beyond composing an
upline. - WebSocket, gRPC, or any second wire format: SSE + JSON.
- Rendering the declared graph (the
querytree withConnect/Overlaycolouring); the live view is the computedDag, the same asrun tree/run dagsince (R4).query’s tree stays a CLI concern.
Decisions taken
POST /commandhas both modes: sync by default, returning the command’s reports;?asyncreturning the enqueue sequence number for a client that reads/events./dagreads the magma. It isworldDagoverworldMagmaand the ledger, the same structure a pass walks, and exists from the first declaration on — no pass required.notesand every other report text are public. No redaction in this server; a sensitive-data story is a separate spec.- One tagged event stream, produced by composing a server
Reporterbeside the existing text one (Salmon.Reporter’s contravariant combinators),contramapped into one sum. Clients filter.
Open questions
- Sequence numbers: one counter for the whole loop, or per report kind? One
(a client resuming wants a single cursor), but
Upkeepreports are emitted from machine threads whileServe/UpDownreports come from the loop, so the counter has to be taken under the sameMVarthe concurrent driver already serialisesrunReporterthrough. Answered in milestone 4: one counter; the critical section is the numbering reporter’s own STM transaction, which the drivers’ (several, local)MVars compose over. - Whether
/dagshould include nodes only retiring declarations still describe (wantedTurnDown, not yet down). Yes, withdirection: down— a teardown in progress is the most useful thing to watch — but the UI needs to draw them differently from live ones.
Suggested milestones
ToJSONfor the three report streams and a--jsonreporter flag on every shipped binary. No server yet;run up | jqworks; every later client reuses the encoding. Test: golden JSON for each constructor.- Unix socket carrying the line protocol (
run serve --listen <path>), as a second producer into the inbox, stdin unchanged. Test: two clients, interleaved commands, reports go to the client that typed them. Shipped (Salmon.Actions.Serve.Socket,Test/ServeSocketSpec.hs). Two deviations: “stdin unchanged” holds for what stdin accepts, not for what its end of input does — under--listenstdin EOF is a hang-up and onlyquitends the loop, since a server started< /dev/null &must not exit at once; and aHungUpreport was added toServe.Report, because “every line this client typed has been handled” is a fact only the loop knows and the socket needs it to close the connection at the right moment. /dag,/status,/history,/help/seedas JSON over HTTP on the socket. TheActprojection lands here. Test:/dagequals whatHelp.printDagTreewould print, structurally. Shipped (Salmon.Actions.Serve.Http,run serve --http PATH,Test/ServeHttpSpec.hs), withPOST /commandin both modes. Two deviations: it is a second unix socket beside--listen’s rather than HTTP detected on the same one (the line protocol reads through aHandlethat cannot give peeked bytes back, so sharing meant rewriting both over raw sockets plus a warpInternalshim, for the price of one flag); and/dagisworldDagunrewritten — nomembers, noremote, nooutput/pathsbeyond whatstatusalready carries — since the loop’s registeredRewrites run per pass and a rewrite-introduced node has noNodeStateto project./historyfoldshistory-elided’s count in as anelidedfield rather than answering with two objects./events(SSE) with sequence numbers and?since=. Test: a client that reconnects mid-pass misses nothing. Shipped (Salmon.Actions.Serve.Events,GET /eventsinSalmon.Actions.Serve.Http,--events-ring N,Test/ServeEventsSpec.hs). The open question on sequence numbers is answered: one counter, and the critical section is an STM transaction owned by the numbering reporter (counter, ring and broadcast written together), not the concurrent driver’sMVar— there is no single suchMVarto take (one per walk, one per supervisor), but each is held whilerunReporterruns, so the transaction composes under all of them. Three deviations: everyPOST /commandpublishes anenqueuedevent numbered from the same counter (so the numbering is dense and the?asyncnumber is an event a client can see); aTendedreport is delivered as its innerUpkeep.Reportunderstream: "upkeep"rather than as{"kind":"tended"}; and?stream=/?origin=filter server-side after all, since a terminal client over a slow link wants less on the wire./dagand/statuscarryseq, read before the snapshot so a race replays rather than skips.modeinstatus//dag. Shipped:statusand/statuswithspecs/pull-mode.mdmilestone 4 (Serve.ModeonStatusReport),/dagas a top-levelmodeon the envelope, read from the server’s accessor at the moment of the request; oneToJSON Serve.ModeinSalmon.Reporter.Taggedserves both./help/seeddoes not carry it.- Terminal client against the socket. Shipped (
salmon-tui PATHinsalmon-apps, overSalmon.Client.Httpand the pureSalmon.Client.Modelinsalmon-ops,Test/ClientModelSpec.hs):/dagonce,/eventsfrom itsseq, a node table with direction, state, last check and last event,enterfor a node’s help/notes/output,:for a command sent?asyncwith its seq echoed,rto re-read, reconnect with?since=, re-read ongap. Three deviations from the “Clients” section. It is a table in/dag’s order (theDag‘s first-seen order, whatrun treeprints), not a tree view: with the edges in both directions on every node and a node appearing once whatever number of paths reach it, a tree would repeat nodes and a table with an expand does not, so the tree waits for the web UI’s layered layout. There is no separate report pane tailing/events: the last event is one footer line and each node’s row carries the last event about it, which is what a pane tailing the stream would mostly be showing; a scrollback of events is a place the client would hold state the server does not. And the model drops a replayed event per stamp (a node’s own seq, the loop’s own seq) rather than by one cursor, because a/dagsnapshot carries the nodes’ state and not the pass’s, so a re-read afterdeclaredwould otherwise swallow theconverge-stopof a pass the client had already shown starting. Two things the spec did not say that the client needed: a snapshot must be rebased onto a folding model (Model.rebase), anddeclaredmust trigger a re-read, since an event names nodes by ref and no event describes a node the client has never seen. - Web UI: static graph from
/dag, then live from/events, then actions, then the seed form. Shipped, all four steps (GET /and/ui/*inSalmon.Actions.Serve.Http, the files undersalmon-ops/ui/embedded at build time withfile-embed;resources/serve-supervision.md§14 “The web UI”). Three deviations from the sketch above. It is not a separatesalmon-webpackage and not PureScript: three static files — one page, one ES module, one stylesheet, no bundler — served by the binary itself, since a UI that ships inside the thing it watches needs no deploy step and the read model is small enough that generated client types would cost more than they save; the bridge stays an option for the seed form. The layered layout is neitherdagrenor ELK but a longest-path layering with barycentre ordering written inui.js, because vendoring a bundle for a graph of tens of nodes is the wrong trade and the layout is a hundred lines. And a browser cannot open a unix socket, so the page is reached through a TCP forward (socat,ssh -L) until milestone 8, which added--http-tcpand the/authsign-in, so a browser now reaches it directly. The actions and the seed form add three more deviations, all on the write side. Every write isPOST /command?asyncand never the synchronous form — the page reads the outcome off/eventsby the request’s origin, which is what marks the nodes a command touched and fills the log under its command line; the sketch’s “what a UI wants” turned out to be the whole of it. The seed form is/help/seed’s text in a<pre>and a free-text field for the words, not a form derived from theParseRecord— no schema is served, and the bridge stays unused. And “retire the seed behind this node” is not a per-node action: a node does not know its declaring seed and/historydoes not list an epoch’s nodes, so the panel offers every live declaration’sdowninstead and the operator picks. Also deliberate: noquiton the page (the page is served by the process it would stop), and no bearer token sent: over--http-tcpthe browser signs in at/authand carries a session cookie instead (milestone 8). Not yet: theConflictingpair side by side (as of 2026-09-25/dagcarries it —conflict: {kept, replaced}on the node, held while a live declaration still wants the losing version — so only the page’s half remains), collapsing a batch to its members and expanding aRemoteOp(neither is on/dag, see milestone 3’s deviations), andhistoryas a timeline — it is a table under the seed form. - TCP + TLS + token, opt-in, with the loud default described above.
Shipped (
run serve --http-tcp HOST:PORT --tls-cert FILE --tls-key FILE --token-file FILE;Http.withHttpServerOn/Http.Bind/Http.requireToken,CommandLine.validateTcpOptions,Test/ServeTlsSpec.hs,resources/serve-supervision.md§14). The sameapplicationon a warp-tls listener beside the unix socket, oneServerfor both; a bearer token on every route of the TCP listener, compared in constant time; the unix socket unchanged and token-free. The loud default is stricter than the text above: not “never on0.0.0.0without TLS and auth” but never on any address without both — there is no plaintext TCP constructor or flag,--http-tcpwithout all three files exits 1 naming the missing ones, and the host is always spelled (:8443is refused,0.0.0.0:8443is how listening everywhere is written), so “default to localhost” is not a default but a choice the operator types. Four deviations. A bearer token from a file, not a JWT:SreBox.JWTSigningsigns for other services and a verifier here would need a key store, an audience and a clock for what achmod 600file already gives; and no client certificate, which is the v2 the text names. The token file must not be readable by others and must not be empty — two refusals the text did not ask for. Commands typed over TCP are attributed to the client’sADDR:PORT#n, not to the listener, sohistorysays who. And theCertificatesnodes can mint the certificate, as the text says, with one caveat found on the way:selfSign/caSign(openssl x509 -req) write X.509 v1 certificates, which crypton’s validation rejects (LeafNotV3) while OpenSSL-based clients accept;certificateAuthority(req -x509) writes v3, and is what the test pins. Not done: the clients —salmon-tuiand the web UI need a--tokenand a TCP address to use this.