Skip to content

Factor runSiteWith into siteInput + runSiteWithInput#179

Merged
srid merged 5 commits intomasterfrom
feat/run-site-with-input
Apr 24, 2026
Merged

Factor runSiteWith into siteInput + runSiteWithInput#179
srid merged 5 commits intomasterfrom
feat/run-site-with-input

Conversation

@srid
Copy link
Copy Markdown
Owner

@srid srid commented Apr 24, 2026

runSiteWith now composes out of two stepssiteInput (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:

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's 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

nix build github:srid/ema/feat/run-site-with-input

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.
srid added a commit to srid/emanote that referenced this pull request Apr 24, 2026
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.
srid added 2 commits April 23, 2026 21:36
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.
@srid
Copy link
Copy Markdown
Owner Author

srid commented Apr 24, 2026

Hickey/Lowy Analysis

# Lens Finding Disposition
1 Hickey MonadFail m on runSiteWithInput unforced — only used by CLI.crash's fail Fixed (569a17f)
2 Hickey Doc snippet in dynamic.md uses superfluous withRunInIO Fixed (5a0399c)
3 Hickey cwd log side-effect inside runSiteWithInput body Deferred — would need a withEmaStartup bracket; blast radius is test-log noise, not correctness
4 Hickey runSiteWith handles logger; runSiteWithInput assumes caller's context No-op — the asymmetry is the point of the factoring
5 Hickey asyncunliftio dep swap No-op — minimal change needed for polymorphic m
6 Lowy siteInputrunSiteWithInput boundary correctness No-op — encapsulates composition volatility exactly once
7 Lowy Constraints undocumented Fixed (folded into 569a17f — inline rationale added)
8 Lowy MonadFail weak contract across monads Fixed (subsumed by #1 — now uses throwIO on userError)
9 Lowy runSiteWith two-liner shape No-op — correct orchestrator role
10 Lowy Forward-compat with future subscribe/fanout No-op — fits the same three-line pattern, no redesign needed

Hickey rationale

The factoring decomplects the right things: runSiteWith now owns logger setup + orchestration, runSiteWithInput owns dispatch (Generate vs Run) + race. What was conflated before (Dynamic acquisition + consumption) is now separable. The one structural smell I found was MonadFail m carried purely because CLI.crash used fail — a weak contract for a "never returns" path. Dropping it via throwIO shrinks both CLI.crash's and generateSiteFromModel's constraint sets, and removes MonadFail from the library entirely.

The cwd log inside runSiteWithInput is a minor braiding of startup observability into Dynamic consumption, but extracting it needs a bracket-style primitive and the blast radius is small. Deferring.

Lowy rationale

The composition-volatility axis (what callers do between Dynamic build and consume) is now fully owned by the caller, which is where it belongs — runSiteWithInput is oblivious to tee/fanout/subscribe patterns. The polymorphism in m correctly exposes driver constraints that were previously hidden inside runSiteWith's IO monomorphism — this is necessary for downstream composition, not a leak. A future subscribe :: Dynamic m a -> m (TChan a, Dynamic m a) primitive drops into the same siteInput → tee → runSiteWithInput three-liner without changing this API — forward-compatible by construction.

The MonadFail finding converged with Hickey #1 and was fixed there.

Comment thread docs/guide/model/dynamic.md Outdated
srid added 2 commits April 24, 2026 07:44
`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 srid merged commit 5dc9874 into master Apr 24, 2026
2 checks passed
@srid srid deleted the feat/run-site-with-input branch April 24, 2026 18:29
srid added a commit to srid/emanote that referenced this pull request Apr 24, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant