Add Ema.Dynamic.currentValue for out-of-band reads#177
Merged
Conversation
Dynamic is push-only: its updater takes a send callback and runs forever. That's a poor fit for consumers that need synchronous pull access to the latest value — e.g. an HTTP handler running alongside runSiteWith that wants to read the current model per request. currentValue tees the Dynamic: it returns an IO reader that yields the latest published value, plus a wrapped Dynamic that must be used in place of the input. The wrapped Dynamic's updater calls the original's updater and additionally stores each value into the IORef backing the reader. No second producer thread, no FS watcher duplication. Motivated by Emanote's MCP server (srid/emanote#645), which needs to expose the notebook model as read-only resources without driving Ema's render loop.
c4124e5 to
3fcae1f
Compare
1ec1238 to
270518c
Compare
srid
added a commit
that referenced
this pull request
Apr 24, 2026
**`runSiteWith` now composes out of two steps** — `siteInput` (build the `Dynamic`) and `runSiteWithInput` (consume it). The same class of problems that motivated `currentValue` (#177) — teeing the `Dynamic` for an out-of-band consumer — needs a seam between those two steps. Before this PR, `runSiteWith` sealed them together; the only escape was plumbing a callback through the user's `SiteArg`, which either conflated config with transport wiring or (worse) bypassed the user's `siteInput` entirely. _The user's `siteInput` still runs._ Callers that need the tee just invoke `siteInput` themselves, compose (`currentValue`, `<*>`, whatever), and pass the result to `runSiteWithInput`. Callers that don't need it keep using `runSiteWith` exactly as before — its body is now a two-liner: ```haskell runSiteWith cfg arg = flip runLoggerLoggingT (getLogger …) $ do dyn <- siteInput @r (CLI.action cli) arg runSiteWithInput cfg dyn ``` `runSiteWithInput` is polymorphic in `m` (`MonadUnliftIO m, MonadLoggerIO m, MonadFail m`), which also lets the live-server race stay in one logger context — `UnliftIO.Async.race_` replaces `Control.Concurrent.Async.race_`, and both branches run in `m` directly. Net: the existing `liftIO $ race_` / `runLoggingT logger` scaffolding collapses, and `async` drops out of the direct deps (it's still pulled in transitively via `unliftio`). > Fully additive. `runSite`, `runSite_`, `runSiteWith` are unchanged in signature and behaviour. `runSiteWithInput` is a new export. Motivated by [srid/emanote#649](srid/emanote#649 MCP server: with this PR, emanote drops its `tapModel` helper, the `_emanoteConfigOnLiveModel` callback field, and the publish-through-config plumbing entirely — MCP and Ema compose via `race_` at the call site. ### Try it locally ```sh nix build github:srid/ema/feat/run-site-with-input ```
2 tasks
srid
added a commit
that referenced
this pull request
Apr 24, 2026
## Summary - Adds a `test` test-suite stanza to `ema.cabal` so feature PRs can land unit tests alongside code. - Seeds it with `Ema.DynamicSpec` exercising `Ema.Dynamic.currentValue` (#177): initial value before any update, and latest value after the wrapped updater runs. - Uses plain `hspec` rather than `hspec-discover` — the nix-provided Haskell env does not expose the `hspec-discover` executable, so specs are wired up manually in `test/Spec.hs`. Closes #178. ## Test plan - [x] `cabal test --enable-tests ema:test:test` — 2 of 2 pass - [x] `cabal-fmt` and `fourmolu` pre-commit hooks pass 🤖 Generated with [Claude Code](https://claude.com/claude-code)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Dynamic is push-only, but some consumers need pull. The updater shape
(a -> m ()) -> m ()is perfect for a render loop that reacts to each new value, but a poor fit for a synchronous request handler — e.g. an HTTP server running alongsiderunSiteWiththat wants to snapshot the current model per request. Before this PR, bridging push to pull meant hand-rolling an IORef tee outside Ema, as srid/emanote#649 did in itstapModel. Moving the primitive into Ema lets downstream apps stop re-deriving the same 8-line pattern.currentValuetees a Dynamic: it returns(reader, wrapped)wherereader :: IO ayields the latest published value (or the initial before any update), andwrapped :: Dynamic m ais a pass-through that must be used in place of the input. The wrapped updater calls the original's updater and additionally stores each value into the IORef backing the reader. No second producer thread, no FS-watcher duplication — there's still one updater running, we just intercept the send callback.Try it locally
Or see it in action once downstream lands: srid/emanote#649