Cross-process gateway event bus.
The gateway's live event log + SSE fan-out (gateway.state) is a
PROCESS-LOCAL in-memory registry: append-event! only reaches
subscribers inside the SAME JVM. That is why a turn streaming in the
TUI process is invisible to a web/Telegram process watching the SAME
conversation — each process has its own registry, and the only thing
they share is the persisted DB (which lands whole turns, not the live
token stream). So two watchers never stream together.
This bus closes that gap with the simplest transport that needs no
schema change and no always-on daemon: a shared append-only journal
under ~/.vis/gateway/events/<sid>.ndjson. Every LOCALLY-produced
gateway event is publish!ed there (one JSON line, tagged with this
process's producer id). A background tailer in each process follows
those files and re-delivers FOREIGN events (producer != self) into the
local registry via a delivery fn wired by gateway.state — so every
process's subscribers see the same stream, live.
Ordering/seq: exactly ONE turn runs per session at a time, so at any
moment a single producer owns the stream and its monotonic :seq is
authoritative for every watcher. The producer truncates the journal at
each turn.started, bounding a file to one turn's worth of deltas;
consumers detect the truncation (offset past EOF) and rewind.
Degrades safely: any IO failure is swallowed and the process falls back to today's in-process-only behavior.
Cross-process gateway event bus. The gateway's live event log + SSE fan-out (`gateway.state`) is a PROCESS-LOCAL in-memory registry: `append-event!` only reaches subscribers inside the SAME JVM. That is why a turn streaming in the TUI process is invisible to a web/Telegram process watching the SAME conversation — each process has its own registry, and the only thing they share is the persisted DB (which lands whole turns, not the live token stream). So two watchers never stream together. This bus closes that gap with the simplest transport that needs no schema change and no always-on daemon: a shared append-only journal under `~/.vis/gateway/events/<sid>.ndjson`. Every LOCALLY-produced gateway event is `publish!`ed there (one JSON line, tagged with this process's `producer` id). A background tailer in each process follows those files and re-delivers FOREIGN events (producer != self) into the local registry via a delivery fn wired by `gateway.state` — so every process's subscribers see the same stream, live. Ordering/seq: exactly ONE turn runs per session at a time, so at any moment a single producer owns the stream and its monotonic `:seq` is authoritative for every watcher. The producer truncates the journal at each `turn.started`, bounding a file to one turn's worth of deltas; consumers detect the truncation (offset past EOF) and rewind. Degrades safely: any IO failure is swallowed and the process falls back to today's in-process-only behavior.
Gateway HTTP/SSE server.
Clojure-native stack: reitit-ring routes -> Ring middleware -> the
Ring Jetty adapter on JDK virtual threads (:virtual-threads? true).
SSE is a Ring StreamableResponseBody that parks its virtual thread
on the connection: replay rides first, live events fan in under the
same output-stream monitor, a heartbeat comment keeps the pipe warm
and detects dead clients.
This is internal plumbing, not a channel: it registers no channel
descriptor and owns no renderer - it ships canonical IR and the
client renders (§4.1). Any host process (the vis serve daemon, a
TUI run, an embedded caller) can start it alongside whatever else it
is doing via start!.
Gateway HTTP/SSE server. Clojure-native stack: reitit-ring routes -> Ring middleware -> the Ring Jetty adapter on JDK virtual threads (`:virtual-threads? true`). SSE is a Ring `StreamableResponseBody` that parks its virtual thread on the connection: replay rides first, live events fan in under the same output-stream monitor, a heartbeat comment keeps the pipe warm and detects dead clients. This is internal plumbing, not a channel: it registers no channel descriptor and owns no renderer - it ships canonical IR and the client renders (§4.1). Any host process (the `vis serve` daemon, a TUI run, an embedded caller) can start it alongside whatever else it is doing via `start!`.
Gateway session manager.
One process-global registry over the live session fleet: per-session
ordered event log (monotonic :seq, ring-buffered), SSE subscriber
fan-out, async turn submission with idempotency keys, cancellation,
and turn/cost metrics.
The engine is reached ONLY through the same internal surfaces the
TUI/Telegram channels use: loop/create!-send!-close! for the
lifecycle, :hooks {:on-chunk ...} phased chunks for the live
stream, ctx-loop/session-snapshot for the context. No engine state
lives here - this namespace owns wire bookkeeping (events, turn
records, subscribers), nothing else.
Gateway session manager.
One process-global registry over the live session fleet: per-session
ordered event log (monotonic `:seq`, ring-buffered), SSE subscriber
fan-out, async turn submission with idempotency keys, cancellation,
and turn/cost metrics.
The engine is reached ONLY through the same internal surfaces the
TUI/Telegram channels use: `loop/create!`-`send!`-`close!` for the
lifecycle, `:hooks {:on-chunk ...}` phased chunks for the live
stream, `ctx-loop/session-snapshot` for the context. No engine state
lives here - this namespace owns wire bookkeeping (events, turn
records, subscribers), nothing else.Wire encoding for the HTTP gateway.
One dumb, deterministic boundary: engine EDN -> JSON. Keyword/symbol
keys become snake_case strings (namespace dropped), keyword values
become their name, non-JSON leaves fall back to str. The walker
makes ZERO semantic decisions - no flattening, no rendering. Canonical
IR vectors pass through structurally ([:ir {...} ...] ->
["ir", {...}, ...]), which is exactly the ALWAYS-IR contract:
the client walks IR; the gateway never renders.
Wire encoding for the HTTP gateway.
One dumb, deterministic boundary: engine EDN -> JSON. Keyword/symbol
keys become snake_case strings (namespace dropped), keyword values
become their name, non-JSON leaves fall back to `str`. The walker
makes ZERO semantic decisions - no flattening, no rendering. Canonical
IR vectors pass through structurally (`[:ir {...} ...]` ->
`["ir", {...}, ...]`), which is exactly the ALWAYS-IR contract:
the client walks IR; the gateway never renders.cljdoc builds & hosts documentation for Clojure/Script libraries
| Ctrl+k | Jump to recent docs |
| ← | Move to previous article |
| → | Move to next article |
| Ctrl+/ | Jump to the search field |