packages feed

tricorder-0.1.0.0: src/Tricorder/Builder.hs

module Tricorder.Builder
    ( component
    , BuildConfig (..)

      -- * Internals exposed for testing
    , NewLoadResult (..)
    , EnteringNewPhase (..)
    , compileLoadResultsIntoBuildResults
    , requestTestRunsForNewBuildResults
    , buildWithGhciOnChange
    , interruptCurrent
    , onRestart
    , reloadOnSourceChange
    , setNewPhase
    , restartOnCabalChange
    ) where

import Atelier.Component (Component (..), defaultComponent)
import Atelier.Effects.Clock (Clock, UTCTime)
import Atelier.Effects.Conc (Conc)
import Atelier.Effects.Debounce (Debounce, debounced)
import Atelier.Effects.Log (Log)
import Atelier.Effects.Publishing (Sub)
import Atelier.Time (Millisecond, nominalDiffTime)
import Control.Concurrent.STM (check, readTVar, retry, writeTVar)
import Data.Default (Default (..))
import Data.Time (diffUTCTime)
import Effectful.Concurrent (Concurrent)
import Effectful.Concurrent.STM (atomically, newTVar)
import Effectful.Exception (finally, trySync)
import Effectful.Reader.Static (Reader, ask)
import Effectful.State.Static.Shared (State, get, modify, put, state)
import System.FilePath (normalise)

import Atelier.Effects.Clock qualified as Clock
import Atelier.Effects.Conc qualified as Conc
import Atelier.Effects.Log qualified as Log
import Atelier.Effects.Publishing qualified as Sub
import Data.Map.Strict qualified as Map
import Data.Set qualified as Set

import Tricorder.BuildState
    ( BuildId (..)
    , BuildPhase (..)
    , BuildResult (..)
    , CabalChangeDetected (..)
    , Diagnostic (..)
    , Severity (..)
    , SourceChangeDetected (..)
    , TestRun (..)
    )
import Tricorder.Builder.Dispatch
    ( BuilderState (..)
    , DispatchAction (..)
    , KnownTargetNames (..)
    , dispatch
    , emptyBuilderState
    , filterToWatchDirs
    , mergeDiagnostics
    )
import Tricorder.Effects.BuildStore (BuildStore)
import Tricorder.Effects.GhciSession (GhciSession, LoadResult (..))
import Tricorder.Effects.GhciSession.GhciParser (resolveKnownTargets)
import Tricorder.Effects.GhciSession.GhciProcess (GhciProcessError (..))
import Tricorder.Effects.SessionStore (SessionStore)
import Tricorder.Effects.TestRunner (TestRunner)
import Tricorder.Runtime (ProjectRoot (..))
import Tricorder.Session (Session (..))

import Tricorder.Effects.BuildStore qualified as BuildStore
import Tricorder.Effects.GhciSession qualified as GhciSession
import Tricorder.Effects.SessionStore qualified as SessionStore
import Tricorder.Effects.TestRunner qualified as TestRunner


-- | Builder component.
-- Starts a GHCi session, performs an initial load, then listens for changes
-- from the watcher.
component
    :: ( BuildStore :> es
       , Clock :> es
       , Conc :> es
       , Concurrent :> es
       , Debounce Text :> es
       , GhciSession :> es
       , Log :> es
       , Reader ProjectRoot :> es
       , SessionStore :> es
       , State BuildId :> es
       , State BuilderState :> es
       , Sub CabalChangeDetected :> es
       , Sub SourceChangeDetected :> es
       , TestRunner :> es
       )
    => Component es
component =
    defaultComponent
        { name = "Builder"
        , listeners = pure [runBuilder defaultGhciSessionHooks]
        }


-- | A subset of 'Session' with just the properties that 'Builder' cares about.
data BuildConfig = BuildConfig
    { command :: Text
    , targets :: [Text]
    , testTargets :: [Text]
    , watchDirs :: [FilePath]
    }
    deriving stock (Eq)


instance Default BuildConfig where
    def =
        BuildConfig
            { command = session.command
            , targets = session.targets
            , testTargets = session.testTargets
            , watchDirs = session.watchDirs
            }
      where
        session = def @Session


--------------------------------------------------------------------------------
-- Level 1 — Builder lifecycle: supervise the tricorder session
--------------------------------------------------------------------------------

-- | Level 1 — supervise the /tricorder session/ (the project config). For the
-- current config, run successive GHCi sessions; whenever a @.cabal@ file
-- changes, reload the config and restart everything.
--
-- Cabal-change events flip a TVar; 'restartOnCabalChange' consumes the flag,
-- runs @preRestart@ (transition to 'Restarting', reload the config),
-- then exits the inner scope which cancels the in-flight GHCi session. The next
-- iteration reloads the config via 'loadBuildConfig' and forks a fresh
-- 'runGhciSessions'.
runBuilder
    :: ( BuildStore :> es
       , Clock :> es
       , Conc :> es
       , Concurrent :> es
       , Debounce Text :> es
       , GhciSession :> es
       , Log :> es
       , Reader ProjectRoot :> es
       , SessionStore :> es
       , State BuildId :> es
       , State BuilderState :> es
       , Sub CabalChangeDetected :> es
       , Sub SourceChangeDetected :> es
       , TestRunner :> es
       )
    => GhciSessionHooks es
    -> Eff es Void
runBuilder hooks =
    restartOnCabalChange preRestart loadBuildConfig session
  where
    -- The tricorder session's restart hook: the outer loop reloading *its own*
    -- config. This is not a GHCi-session concern, so it lives here rather than
    -- in 'GhciSessionHooks'.
    preRestart = do
        -- Flip the UI to 'Restarting' immediately so the user sees the change
        -- has been picked up before scope teardown (which kills cabal repl and
        -- waits for graceful exit).
        enterPhase Restarting
        -- Pick up cabal/package.yaml edits before the next iteration.
        SessionStore.rawReload

    session config = runGhciSessions hooks config `finally` onRestart


-- | Read the current tricorder 'Session' and project the parts the Builder
-- cares about. Runs at the start of each restart iteration.
loadBuildConfig
    :: ( Log :> es
       , SessionStore :> es
       )
    => Eff es BuildConfig
loadBuildConfig = do
    session <- SessionStore.get
    let config =
            BuildConfig
                { command = session.command
                , targets = session.targets
                , testTargets = session.testTargets
                , watchDirs = session.watchDirs
                }
    Log.info $ "Builder.component: resolved command = " <> config.command
    pure config


onRestart
    :: ( BuildStore :> es
       , Log :> es
       , State BuildId :> es
       )
    => Eff es ()
onRestart = do
    Log.info "Restarting builder..."
    buildId <- get
    modify @BuildId (+ 1)
    setNewPhase $ EnteringNewPhase buildId $ Building Nothing


--------------------------------------------------------------------------------
-- Level 2 — GHCi-session lifecycle
--------------------------------------------------------------------------------

-- | The lifecycle hooks for one /GHCi session/. Each field is one moment in a
-- GHCi session's life; the coordinators below (@runGhciSessions@ /
-- @watchSourceChanges@) own the /when/, these own the /what/.
--
-- These are deliberately GHCi-session hooks only. Reloading the /tricorder
-- session/ (the project config) on a @.cabal@ change belongs to the outer loop
-- and lives inline in 'runBuilder' — keeping it out of here is what stops a
-- \"child restarting its parent\". Tests supply their own record to observe a
-- single hook in isolation.
data GhciSessionHooks es = GhciSessionHooks
    { onStart :: Eff es ()
    -- ^ A fresh GHCi session is about to launch: reset accumulated state.
    , onInitialLoad :: BuildConfig -> NewLoadResult -> Eff es ()
    -- ^ The initial load finished: run the post-load pipeline.
    , onSourceChange :: BuildConfig -> GhciSession.Controls (Eff es) -> SourceChangeDetected -> Eff es ()
    -- ^ A debounced source change arrived: run one build/test cycle.
    , onStartupFail :: SomeException -> Eff es ()
    -- ^ GHCi failed to start: surface 'BuildFailed' and wait to retry.
    }


-- | The default GHCi-session hooks.
defaultGhciSessionHooks
    :: ( BuildStore :> es
       , Clock :> es
       , Log :> es
       , Reader ProjectRoot :> es
       , State BuildId :> es
       , State BuilderState :> es
       , Sub SourceChangeDetected :> es
       , TestRunner :> es
       )
    => GhciSessionHooks es
defaultGhciSessionHooks =
    GhciSessionHooks
        { onStart = put emptyBuilderState
        , onInitialLoad = afterLoad
        , onSourceChange = reloadOnSourceChange
        , onStartupFail = recoverFromStartupFailure
        }


-- | Level 2 — run successive /GHCi sessions/ for one tricorder session, each
-- retried on startup failure. Reads top-to-bottom as a GHCi session's phases:
--
--   1. 'onStart' — reset accumulated state.
--   2. launch GHCi; on its initial load, 'onInitialLoad' runs the pipeline.
--   3. 'watchSourceChanges' — the inner loop, until the session is torn down.
--   4. 'onStartupFail' — if the launch itself threw, recover and retry.
runGhciSessions
    :: ( BuildStore :> es
       , Clock :> es
       , Conc :> es
       , Concurrent :> es
       , Debounce Text :> es
       , GhciSession :> es
       , Log :> es
       , Reader ProjectRoot :> es
       , State BuildId :> es
       , State BuilderState :> es
       , Sub SourceChangeDetected :> es
       , TestRunner :> es
       )
    => GhciSessionHooks es
    -> BuildConfig
    -> Eff es Void
runGhciSessions hooks config = forever do
    hooks.onStart
    root@(ProjectRoot rootPath) <- ask
    BuildId n <- get
    Log.info $ "Starting GHCi session #" <> show n <> ": " <> config.command

    startTime <- Clock.currentTime
    result <- trySync $ GhciSession.withGhci config.command root \initialLoad controls -> do
        endTime <- Clock.currentTime
        let filteredMsgs = filterToWatchDirs rootPath config.watchDirs initialLoad.diagnostics
        Log.info
            $ mconcat
                ["GHCi started (session #", show n, "): ", show (length filteredMsgs), " diagnostics"]
        hooks.onInitialLoad config NewLoadResult {startTime, endTime, loadResult = initialLoad}
        modify \s ->
            s
                { loadedModules = resolveKnownTargets Map.empty initialLoad
                , knownTargets = KnownTargetNames (Set.fromList initialLoad.targetNames)
                }
        watchSourceChanges hooks config controls
    case result of
        Right _ -> pure ()
        Left ex -> hooks.onStartupFail ex


-- | The GHCi-session loop with the default 'defaultGhciSessionHooks'. Kept as a
-- named entry point for tests; 'runBuilder' wraps this with the tricorder
-- session's cabal-restart handling.
buildWithGhciOnChange
    :: ( BuildStore :> es
       , Clock :> es
       , Conc :> es
       , Concurrent :> es
       , Debounce Text :> es
       , GhciSession :> es
       , Log :> es
       , Reader ProjectRoot :> es
       , State BuildId :> es
       , State BuilderState :> es
       , Sub SourceChangeDetected :> es
       , TestRunner :> es
       )
    => BuildConfig
    -> Eff es Void
buildWithGhciOnChange = runGhciSessions defaultGhciSessionHooks


-- | Default 'onStartupFail': surface 'BuildFailed', then wait for a source
-- change to retry the launch.
--
-- A cabal change is handled out-of-band by 'runBuilder' cancelling this scope.
-- Without the wait, a startup failure was a dead end: the user could fix the
-- offending source file and nothing would happen, because no
-- 'SourceChangeDetected' listener is active on this path.
recoverFromStartupFailure
    :: ( BuildStore :> es
       , Log :> es
       , State BuildId :> es
       , Sub SourceChangeDetected :> es
       )
    => SomeException -> Eff es ()
recoverFromStartupFailure ex = do
    Log.err $ "GHCi session failed to start: " <> show ex
    enterPhase $ BuildFailed $ renderStartupError ex
    Log.info "Build command failed; waiting for a source change to retry"
    void $ Sub.listenOnce_ @SourceChangeDetected


--------------------------------------------------------------------------------
-- Level 3 — Source-change loop and build/test cycle
--------------------------------------------------------------------------------

-- | Level 3 — the inner loop. Wait for source changes and drive one build/test
-- cycle ('onSourceChange') per change.
--
-- Coalesce debounced source-change events through a single-slot register: the
-- debounced listener writes the latest event into the slot, and a single worker
-- fork drains it. Events that arrive while the worker is processing the previous
-- one simply overwrite the slot, so a burst of N touches collapses into exactly
-- one trailing cycle (carrying the most recent event) rather than queueing N
-- back-to-back cycles. This matters whenever 'interruptCurrent' can't drop the
-- in-flight cycle promptly — e.g. when a 'status --wait' caller has registered
-- as a waiter, gating 'interruptCurrent' to a no-op.
watchSourceChanges
    :: ( BuildStore :> es
       , Conc :> es
       , Concurrent :> es
       , Debounce Text :> es
       , Log :> es
       , State BuildId :> es
       , Sub SourceChangeDetected :> es
       , TestRunner :> es
       )
    => GhciSessionHooks es
    -> BuildConfig
    -> GhciSession.Controls (Eff es)
    -> Eff es Void
watchSourceChanges hooks config controls = Conc.scoped do
    BuildId n <- get
    Log.debug $ "Builder: waiting for dirty flag (build #" <> show n <> ")"
    forever $ Conc.scoped do
        pending <- atomically (newTVar @(Maybe SourceChangeDetected) Nothing)
        Conc.fork_ $ Sub.listen_ \ev ->
            debounced 200 "source_change_reloader"
                $ atomically (writeTVar pending (Just ev))
        Conc.fork_ $ Sub.listen_ \_ -> interruptCurrent controls
        Conc.fork_ $ forever do
            ev <- atomically do
                readTVar pending >>= \case
                    Nothing -> retry
                    Just e -> writeTVar pending Nothing >> pure e
            hooks.onSourceChange config controls ev
        Conc.awaitAll


-- 'controls.interrupt' is a safe no-op when GHCi is idle, and 'GhciSession'
-- serialises subsequent reloads through its own STM state.
interruptCurrent
    :: ( BuildStore :> es
       , Log :> es
       , TestRunner :> es
       )
    => GhciSession.Controls (Eff es) -> Eff es ()
interruptCurrent controls = do
    hasWaiters <- BuildStore.hasWaiters
    unless hasWaiters do
        Log.info "Change detected with no waiters. Interrupting current build/tests."
        controls.interrupt
        TestRunner.interruptCurrent


reloadOnSourceChange
    :: ( BuildStore :> es
       , Clock :> es
       , Log :> es
       , Reader ProjectRoot :> es
       , State BuildId :> es
       , State BuilderState :> es
       , TestRunner :> es
       )
    => BuildConfig
    -> GhciSession.Controls (Eff es)
    -> SourceChangeDetected
    -> Eff es ()
reloadOnSourceChange config controls (SourceChangeDetected fp event) = do
    Log.debug $ "Builder: source change detected " <> show event <> " " <> toText fp
    builderState <- get @BuilderState
    let known = Map.lookup (normalise fp) builderState.loadedModules
    case dispatch builderState.knownTargets known fp event of
        Nothing ->
            Log.debug
                $ "Builder: no-op for "
                    <> show event
                    <> " of file not loaded in GHCi: "
                    <> toText fp
        Just action -> do
            enterPhase $ Building Nothing

            res <- trySync do
                startTime <- Clock.currentTime
                res <- runAction controls action
                endTime <- Clock.currentTime
                pure (startTime, endTime, res)

            case res of
                Left e -> do
                    now <- Clock.currentTime
                    Log.err $ show now <> " Reload errored: " <> show e
                    -- Resolve the UI instead of stranding it in 'Building': a
                    -- reload that errors (rather than producing a result) must
                    -- not leave the daemon stuck until the next source change
                    -- happens to arrive and succeed.
                    enterPhase $ BuildFailed $ "Reload failed: " <> toText (displayException e)
                Right (startTime, endTime, loadResult) -> do
                    modify \s ->
                        s
                            { loadedModules = resolveKnownTargets s.loadedModules loadResult
                            , knownTargets = KnownTargetNames (Set.fromList loadResult.targetNames)
                            }
                    afterLoad config NewLoadResult {startTime, endTime, loadResult}


runAction :: GhciSession.Controls (Eff es) -> DispatchAction -> Eff es LoadResult
runAction controls = \case
    Reload -> controls.reload
    Add fp -> controls.add fp
    Unadd mn -> controls.unadd mn


-- | Run the post-load pipeline synchronously: compile diagnostics into a
-- 'BuildResult', then (optionally) run tests and transition through the
-- corresponding phases.
afterLoad
    :: ( BuildStore :> es
       , Log :> es
       , Reader ProjectRoot :> es
       , State BuildId :> es
       , State BuilderState :> es
       , TestRunner :> es
       )
    => BuildConfig -> NewLoadResult -> Eff es ()
afterLoad config newLoadResult = do
    buildResult <- compileLoadResultsIntoBuildResults config newLoadResult
    requestTestRunsForNewBuildResults config buildResult


compileLoadResultsIntoBuildResults
    :: ( Reader ProjectRoot :> es
       , State BuilderState :> es
       )
    => BuildConfig
    -> NewLoadResult
    -> Eff es BuildResult
compileLoadResultsIntoBuildResults session newLoadResult = do
    ProjectRoot projectRoot <- ask
    let filteredResult =
            loadResult
                { GhciSession.diagnostics =
                    filterToWatchDirs projectRoot watchDirs loadResult.diagnostics
                }

    newAccumulated <- state \s ->
        let merged = mergeDiagnostics s.diagnosticMap filteredResult
        in  (merged, s {diagnosticMap = merged})

    pure
        BuildResult
            { completedAt = endTime
            , duration = nominalDiffTime (diffUTCTime endTime startTime) :: Millisecond
            , moduleCount = loadResult.moduleCount
            , diagnostics = sortOn (\d -> (d.severity, d.file, d.line, d.col)) $ concat (Map.elems newAccumulated)
            , testRuns = []
            }
  where
    BuildConfig {watchDirs} = session
    NewLoadResult {startTime, endTime, loadResult} = newLoadResult


requestTestRunsForNewBuildResults
    :: ( BuildStore :> es
       , Log :> es
       , State BuildId :> es
       , TestRunner :> es
       )
    => BuildConfig
    -> BuildResult
    -> Eff es ()
requestTestRunsForNewBuildResults config partialResult = do
    buildId <- get
    runTestsIfClean config buildId partialResult >>= \case
        Nothing -> Log.info "Test run aborted by source change; skipping Done transition."
        Just testRuns ->
            setNewPhase $ EnteringNewPhase buildId $ Done partialResult {testRuns}


-- Run all configured test suites if the build has no errors.
-- Transitions to 'Testing' phase while suites are running.
--
-- Returns 'Nothing' if the run was aborted mid-flight by a source change
-- (the caller should not transition to a Done phase in that case). Returns
-- 'Just' with the collected results otherwise.
runTestsIfClean
    :: ( BuildStore :> es
       , Log :> es
       , TestRunner :> es
       )
    => BuildConfig
    -> BuildId
    -> BuildResult
    -> Eff es (Maybe [TestRun])
runTestsIfClean (BuildConfig {testTargets}) bid partialResult
    | null testTargets || any (\d -> d.severity == SError) partialResult.diagnostics = pure (Just [])
    | otherwise = do
        TestRunner.resetAbort
        setNewPhase
            $ EnteringNewPhase bid
            $ Testing partialResult {testRuns = map (`TestRunning` Nothing) testTargets}

        Log.info $ "Running " <> show (length testTargets) <> " test suite(s)"

        let initial = (\t -> (t, TestRunning t Nothing)) <$> testTargets
        runLoop initial testTargets
  where
    runLoop acc [] = pure (Just (snd <$> acc))
    runLoop acc (target : rest) = do
        Log.info $ "Running tests: " <> target
        result <- TestRunner.runTestSuite target
        aborted <- TestRunner.isAborted
        if aborted then
            pure Nothing
        else do
            let acc' = insert target result acc
            setNewPhase
                $ EnteringNewPhase bid
                $ Testing partialResult {testRuns = snd <$> acc'}
            runLoop acc' rest

    insert _ _ [] = []
    insert k v ((k', v') : xs)
        | k == k' = (k, v) : xs
        | otherwise = (k', v') : insert k v xs


--------------------------------------------------------------------------------
-- Restart machinery
--------------------------------------------------------------------------------

-- | Run @action@ in a loop that restarts whenever a 'CabalChangeDetected'
-- event arrives. At most one iteration of @action@ runs at any moment: cabal
-- events arriving during a restart collapse into a single next iteration.
--
-- A TVar flag funnels the events: the cabal listener writes 'True' to it; a
-- single coordinator drains it via 'restartableForkWith', runs @preRestart@,
-- and exits the inner scope. Scope teardown cancels the current @action@
-- (including the 'cabal repl' subprocess via its bracket) and waits for it to
-- finish before the next iteration starts — so we never have two builders
-- racing for the same dist-newstyle directory.
restartOnCabalChange
    :: ( Conc :> es
       , Concurrent :> es
       , Log :> es
       , Sub CabalChangeDetected :> es
       )
    => Eff es ()
    -- ^ Pre-restart hook: runs in the coordinator thread after the flag has
    -- been drained but before the inner scope is torn down. Use this to set
    -- the UI to 'Restarting' and reload the session.
    -> Eff es r
    -- ^ Setup: runs at the start of each iteration, before @action@ is forked.
    -> (r -> Eff es Void)
    -- ^ Inner action. Must never return.
    -> Eff es Void
restartOnCabalChange preRestart setup action = do
    needsRestart <- atomically (newTVar False)
    Conc.scoped do
        Conc.fork_ $ Conc.restartableForkWith (signal needsRestart) setup action
        Sub.listen_ @CabalChangeDetected $ \(CabalChangeDetected path event) -> do
            Log.info $ "Cabal file changed (" <> show event <> " " <> toText path <> "); queued restart"
            atomically (writeTVar needsRestart True)
  where
    signal needsRestart = do
        atomically do
            check =<< readTVar needsRestart
            writeTVar needsRestart False
        preRestart


--------------------------------------------------------------------------------
-- Phase-transition helpers
--------------------------------------------------------------------------------

setNewPhase
    :: (BuildStore :> es)
    => EnteringNewPhase -> Eff es ()
setNewPhase (EnteringNewPhase bid phase) =
    BuildStore.setPhase bid phase


-- | Transition the /current/ build into @phase@.
enterPhase
    :: ( BuildStore :> es
       , State BuildId :> es
       )
    => BuildPhase -> Eff es ()
enterPhase phase = do
    buildId <- get
    setNewPhase $ EnteringNewPhase buildId phase


renderStartupError :: SomeException -> Text
renderStartupError ex = case fromException ex of
    Just (StartupFailed msg) -> msg
    Just StartupTimeout -> "Build command did not produce a GHCi banner before timing out."
    Just (UnexpectedExit cmd lastLine) ->
        "Build command exited unexpectedly: "
            <> cmd
            <> maybe "" (\l -> "\n" <> l) lastLine
    Nothing -> toText (displayException ex)


--------------------------------------------------------------------------------
-- Supporting types
--------------------------------------------------------------------------------

data NewLoadResult = NewLoadResult
    { startTime :: UTCTime
    , endTime :: UTCTime
    , loadResult :: LoadResult
    }
    deriving stock (Eq, Show)


-- | A pending phase transition. Carried by 'setNewPhase' into the 'BuildStore'.
data EnteringNewPhase = EnteringNewPhase BuildId BuildPhase
    deriving stock (Eq, Show)