Factor runSiteWith into siteInput + runSiteWithInput#179
Conversation
Factor runSiteWith into siteInput + runSiteWithInput. The latter takes
a pre-built Dynamic so callers can insert logic — currentValue-tee,
applicative composition, test harnesses — between construction and
consumption without bypassing their EmaSite.siteInput implementation.
runSiteWith collapses to the natural composition:
runSiteWith cfg arg = runLoggerLoggingT (do
dyn <- siteInput @r …
runSiteWithInput cfg dyn) logger
runSiteWithInput is polymorphic in m (same MonadUnliftIO + MonadLoggerIO
constraints siteInput already uses), so the caller stays in one logger
context. UnliftIO.Async.race_ replaces Control.Concurrent.Async.race_ so
both branches of the live-server race run in m directly — no more
liftIO/runLoggingT dance.
Motivated by Emanote's MCP server (srid/emanote#645), which teed the
Dynamic via a callback-through-config workaround. With runSiteWithInput
that plumbing drops entirely: Emanote calls siteInput, currentValue's
it, races its HTTP server against runSiteWithInput, done.
With srid/ema#179, emanote calls its own siteInput, applies currentValue to tee the Dynamic, and hands the wrapped Dynamic to runSiteWithInput — racing the MCP server against Ema's live loop at the Emanote.run level. This retires three layers of phase-2 scaffolding: - EmanoteConfig loses _emanoteConfigOnLiveModel (the publish-callback field that let siteInput hand the reader back to Emanote.run). - Emanote.MCP loses LiveModel / newLiveModel / publishLiveModel (the blocking-MVar handle introduced to bridge the publish/consume race). - Emanote's EmaSite instance's siteInput collapses back to its pre-MCP body — a plain emanoteSiteInput <&> modelUpdateCachedFields. Net: -39 lines of plumbing. MCP and Ema compose via race_ at the call site, with currentValue sitting exactly where it belongs. Ema input still pinned to feat/run-site-with-input pending #179 merge.
Hickey finding: runSiteWithInput required MonadFail only because CLI.crash called 'fail'. MonadFail on a live-server "never returns" path is a surprising constraint for callers assembling a polymorphic monad stack — worse, 'fail' has a weak contract across monads (Lowy F3 also flagged this as dormant risk). Replace 'fail' with throwIO on a userError in CLI.crash. MonadLoggerIO already implies MonadIO, so the constraint shrinks to (MonadLoggerIO m) for crash, and (MonadIO m, MonadLoggerIO m, ...) for generateSiteFromModel and runSiteWithInput — no MonadFail anywhere in the library. Also adds inline rationale for the remaining runSiteWithInput constraints (Lowy F2).
Hickey finding: runSiteWithInput is already MonadUnliftIO, so UnliftIO.Async.race_ works directly on both branches without needing withRunInIO. The snippet in the Haddock comment already had the right shape; the prose docs lagged behind.
Hickey/Lowy Analysis
Hickey rationaleThe factoring decomplects the right things: The Lowy rationaleThe composition-volatility axis (what callers do between Dynamic build and consume) is now fully owned by the caller, which is where it belongs — The |
`runSiteWith` calls siteInput internally — it does not accept a pre-built Dynamic, so the first snippet was misleading. Keep only the correct runSiteWithInput example.
srid/ema#179 landed; drop the branch pin.
runSiteWithnow composes out of two steps —siteInput(build theDynamic) andrunSiteWithInput(consume it). The same class of problems that motivatedcurrentValue(#177) — teeing theDynamicfor an out-of-band consumer — needs a seam between those two steps. Before this PR,runSiteWithsealed them together; the only escape was plumbing a callback through the user'sSiteArg, which either conflated config with transport wiring or (worse) bypassed the user'ssiteInputentirely.The user's
siteInputstill runs. Callers that need the tee just invokesiteInputthemselves, compose (currentValue,<*>, whatever), and pass the result torunSiteWithInput. Callers that don't need it keep usingrunSiteWithexactly as before — its body is now a two-liner:runSiteWithInputis polymorphic inm(MonadUnliftIO m, MonadLoggerIO m, MonadFail m), which also lets the live-server race stay in one logger context —UnliftIO.Async.race_replacesControl.Concurrent.Async.race_, and both branches run inmdirectly. Net: the existingliftIO $ race_/runLoggingT loggerscaffolding collapses, andasyncdrops out of the direct deps (it's still pulled in transitively viaunliftio).Motivated by srid/emanote#649's MCP server: with this PR, emanote drops its
tapModelhelper, the_emanoteConfigOnLiveModelcallback field, and the publish-through-config plumbing entirely — MCP and Ema compose viarace_at the call site.Try it locally