packages feed

synapse-cc-0.3.2: src/SynapseCC/Watch.hs

{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE ScopedTypeVariables #-}

-- | Watch mode: poll backend for changes, incrementally rebuild affected plugins
module SynapseCC.Watch
  ( -- * Entry point
    runWatch

    -- * Pure utilities (exported for testing)
  , matchesAnyPrefix
  , parseUrl
  , extractPluginHashesFromBytes
  , filterTargets
  , buildConfigFromTarget

    -- * IO utilities (exported for testing)
  , fetchBackendHash
  , makeSubstrateConfig
  ) where

import Control.Concurrent (threadDelay)
import Control.Exception (try, SomeException)
import Control.Monad (forM_, when, unless)
import Data.Aeson (Value)
import qualified Data.Aeson as Aeson
import qualified Data.Aeson.Key as AKey
import qualified Data.Aeson.KeyMap as KM
import qualified Data.ByteString as BS
import qualified Data.ByteString.Lazy as BL
import Data.IORef
import Data.Map.Strict (Map)
import qualified Data.Map.Strict as Map
import Data.Maybe (fromMaybe, mapMaybe, isJust)
import Data.Text (Text)
import qualified Data.Text as T
import Data.Time.Clock (getCurrentTime, diffUTCTime)
import System.Directory (createDirectoryIfMissing, doesFileExist)
import System.FilePath ((</>))

import Plexus.Client (SubstrateConfig(..), defaultConfig)
import qualified Plexus.Transport as Transport
import Plexus.Types (PlexusStreamItem(..))
import Synapse.Monad (initEnv, runSynapseM)
import Synapse.IR.Builder (buildIR)
import qualified Synapse.Log as Log
import qualified Katip

import SynapseCC.Cache (expandTilde)
import qualified SynapseCC.Config as Config
import SynapseCC.Config (loadSynapseConfig)
import SynapseCC.Logging
import SynapseCC.Merge (applyMerge)
import SynapseCC.Pipeline (formatSynapseError, generateCode, runPipeline)
import SynapseCC.Types

-- ============================================================================
-- Public Entry Point
-- ============================================================================

-- | Run the watch loop.
-- 1. Load synapse.config.json (falls back to defaults from CLI args)
-- 2. Full initial build across all active targets
-- 3. Poll {backend}.hash every pollInterval ms:
--    - unchanged → no-op
--    - changed   → fetch IR, diff plugin hashes, partial-rebuild changed plugins
runWatch :: WatchArgs -> ToolLocations -> IO ()
runWatch watchArgs tools = do
  let debug  = optDebug (waOptions watchArgs)
      bkName = waBackend watchArgs

  -- Load synapse.config.json
  cfgResult <- loadSynapseConfig
  synapseConfig <- case cfgResult of
    Left err -> do
      logInfo $ "[!] Config: " <> err
      logInfo   "    Falling back to CLI defaults"
      pure (synapseConfigFromArgs watchArgs)
    Right cfg -> pure cfg

  let allTargets    = scTargets synapseConfig
      activeTargets = filterTargets watchArgs allTargets

  when (Map.null activeTargets) $
    logInfo "[!] No targets with \"plugins\" in their generate list — nothing to watch"

  -- Full initial build for each active target
  logStep "Running initial build..."
  forM_ (Map.toList activeTargets) $ \(targetName, targetCfg) -> do
    let config = buildConfigFromTarget watchArgs synapseConfig targetCfg
    result <- runPipeline config tools
    case result of
      Left err -> let msg  = T.strip (formatError err)
                      bare = maybe msg T.stripStart (T.stripPrefix "[!] Error:" msg)
                  in logError $ targetName <> ": " <> bare
      Right _  -> logSuccess $ targetName <> " built"

  -- State: last seen backend hash
  lastHashRef <- newIORef (Nothing :: Maybe Text)

  -- Config hot-reload state
  let hotReload = wcHotReload (scWatch synapseConfig)
      pollMs    = maybe (wcPollInterval (scWatch synapseConfig)) id (waInterval watchArgs)
      pollUs    = pollMs * 1000
  configRef <- newIORef synapseConfig

  when hotReload $
    logInfo "  Config hot-reload enabled (watching synapse.config.json)"

  logInfo $ "Watching " <> bkName <> " (polling every " <> T.pack (show pollMs) <> "ms)..."

  let loop = do
        -- Hot-reload config if enabled
        when hotReload $ do
          newCfgResult <- loadSynapseConfig
          case newCfgResult of
            Right newCfg -> writeIORef configRef newCfg
            Left _       -> pure ()  -- keep old config on error

        currentCfg <- readIORef configRef
        let subCfg  = makeSubstrateConfig watchArgs currentCfg

        hashResult <- (try (fetchBackendHash subCfg bkName) :: IO (Either SomeException (Either Text Text)))

        case hashResult of
          Left ex -> logInfo $ "[!] Backend unreachable: " <> T.pack (show ex)

          Right (Left err) -> logInfo $ "[!] Hash poll error: " <> err

          Right (Right newHash) -> do
            lastHash <- readIORef lastHashRef
            when (Just newHash /= lastHash) $ do
              -- Skip partial rebuild on first poll — initial build covered it
              when (isJust lastHash) $
                handleHashChange watchArgs currentCfg tools debug newHash

              writeIORef lastHashRef (Just newHash)

        threadDelay pollUs
        loop

  loop

-- ============================================================================
-- Hash Polling
-- ============================================================================

-- | Build a SubstrateConfig from watch args + synapse config URL
makeSubstrateConfig :: WatchArgs -> SynapseConfig -> SubstrateConfig
makeSubstrateConfig watchArgs synapseConfig =
  let (host, portTxt) = parseUrl (fromMaybe "ws://127.0.0.1:4444" (scUrl synapseConfig))
      port = read (T.unpack portTxt) :: Int
      bk   = waBackend watchArgs
  in (defaultConfig bk)
       { substrateHost = T.unpack host
       , substratePort = port
       }

-- | Call {backend}.hash and return the composite hash string.
fetchBackendHash :: SubstrateConfig -> Text -> IO (Either Text Text)
fetchBackendHash cfg bkName = do
  let method = bkName <> ".hash"
  result <- Transport.rpcCallWith cfg method Aeson.Null
  case result of
    Left err   -> pure $ Left $ T.pack (show err)
    Right items ->
      case mapMaybe extractHashValue items of
        (Right h : _) -> pure $ Right h
        (Left e  : _) -> pure $ Left e
        []             -> pure $ Left "No hash in response"

-- | Extract a hash string from a Plexus stream item for {backend}.hash calls.
-- Substrate sends content_type "{ns}.hash" with body {"event":"hash","value":"..."}.
extractHashValue :: PlexusStreamItem -> Maybe (Either Text Text)
extractHashValue (StreamData _ _ ct dat)
  | ".hash" `T.isSuffixOf` ct =
      case dat of
        Aeson.Object o ->
          case ( KM.lookup (AKey.fromText "event") o
               , KM.lookup (AKey.fromText "value") o ) of
            (Just (Aeson.String "hash"), Just (Aeson.String v)) -> Just (Right v)
            _ -> Nothing
        _ -> Nothing
  | otherwise = Nothing
extractHashValue (StreamError _ _ err _) = Just (Left err)
extractHashValue _                        = Nothing

-- ============================================================================
-- Change Handling
-- ============================================================================

-- | Handle a backend hash change: fetch full IR, diff plugin hashes, rebuild changed plugins.
handleHashChange
  :: WatchArgs
  -> SynapseConfig
  -> ToolLocations
  -> Bool    -- ^ debug
  -> Text    -- ^ new backend hash (informational)
  -> IO ()
handleHashChange watchArgs synapseConfig tools debug _newHash = do
  let bkName = waBackend watchArgs
      opts   = waOptions watchArgs
      (host, portTxt) = parseUrl (fromMaybe "ws://127.0.0.1:4444" (scUrl synapseConfig))
      port = read (T.unpack portTxt) :: Int
      pluginFilter = waPlugins watchArgs
      generatorInfo = ["synapse-cc:" <> synapseCCVersion]

  logInfo "Backend changed — fetching IR..."
  t0 <- getCurrentTime

  -- Fetch fresh IR via plexus-synapse library
  logger <- Log.makeLogger Katip.ErrorS
  env <- initEnv host port bkName logger
  irResult <- runSynapseM env (buildIR generatorInfo [])

  case irResult of
    Left synapseErr ->
      logInfo $ "[!] IR fetch failed: " <> formatSynapseError synapseErr

    Right ir -> do
      -- Write IR to cache path
      cacheDir <- expandTilde (optCacheDir opts)
      let irDir  = cacheDir </> "synapse" </> "ir" </> T.unpack bkName
          irFile = irDir </> "ir.json"
      createDirectoryIfMissing True irDir
      let irBytes = BL.toStrict (Aeson.encode ir)
      BS.writeFile irFile irBytes

      t1 <- getCurrentTime
      let fetchMs = round (diffUTCTime t1 t0 * 1000) :: Int

      -- Extract per-plugin hashes from fresh IR JSON
      let freshHashes = extractPluginHashesFromBytes irBytes

      -- Diff against cache to find changed/removed plugin namespaces
      (changedNs, removedNs) <- diffPluginHashes opts bkName freshHashes

      -- Apply prefix filter from CLI
      let filteredNs = if null pluginFilter
            then changedNs
            else filter (matchesAnyPrefix pluginFilter) changedNs

      let activeTargets = filterTargets watchArgs (scTargets synapseConfig)

      -- If plugins were removed from the backend, trigger a full rebuild per target.
      -- A partial rebuild cannot clean up stale files or fix index.ts exports.
      unless (null removedNs) $ do
        logInfo $ "  " <> T.pack (show (length removedNs))
               <> " plugin(s) removed — triggering full rebuild"
        when debug $ forM_ removedNs $ \ns ->
          logInfo $ "    - " <> ns
        forM_ (Map.toList activeTargets) $ \(targetName, targetCfg) -> do
          let config = buildConfigFromTarget watchArgs synapseConfig targetCfg
          result <- runPipeline config tools
          case result of
            Left err ->
              let msg  = T.strip (formatError err)
                  bare = maybe msg T.stripStart (T.stripPrefix "[!] Error:" msg)
              in logError $ targetName <> ": " <> bare
            Right _  -> logSuccess $ targetName <> " rebuilt (structural change)"

      if null filteredNs
        then when (null removedNs) $
               logInfo $ "  IR fetched in " <> T.pack (show fetchMs) <> "ms — no plugin changes"
        else do
          logInfo $ "  IR fetched in " <> T.pack (show fetchMs) <> "ms — "
                 <> T.pack (show (length filteredNs)) <> " plugin(s) changed"

          -- Rebuild each changed plugin across affected targets
          forM_ filteredNs $ \ns -> do
            forM_ (Map.toList activeTargets) $ \(targetName, targetCfg) ->
              when ("plugins" `elem` tcGenerate targetCfg || "all" `elem` tcGenerate targetCfg) $ do
                tStart <- getCurrentTime
                rebuildResult <- rebuildPlugin watchArgs synapseConfig tools (IRPath irFile) ns targetCfg debug
                tEnd <- getCurrentTime
                let ms = round (diffUTCTime tEnd tStart * 1000) :: Int
                case rebuildResult of
                  Left err -> logInfo $ "  [!] " <> ns <> " (" <> targetName <> "): " <> err
                  Right () -> logSuccess $ ns <> " (" <> targetName <> ") rebuilt in " <> T.pack (show ms) <> "ms"

-- | Rebuild a single plugin namespace for a single target using hub-codegen.
rebuildPlugin
  :: WatchArgs
  -> SynapseConfig
  -> ToolLocations
  -> IRPath
  -> Text         -- ^ plugin namespace
  -> TargetConfig
  -> Bool         -- ^ debug
  -> IO (Either Text ())
rebuildPlugin watchArgs synapseConfig tools irPath ns targetCfg _debug = do
  let config    = buildConfigFromTarget watchArgs synapseConfig targetCfg
      outputDir = tcOutputDir targetCfg

  codeResult <- generateCode config tools irPath (Just [ns])
  case codeResult of
    Left err -> pure $ Left $ formatError err
    Right out -> do
      let files      = coFiles out
          fileHashes = coFileHashes out
      _mergeResult <- applyMerge files fileHashes Map.empty outputDir
      pure $ Right ()

-- ============================================================================
-- Plugin Hash Helpers
-- ============================================================================

-- | Extract per-plugin composite hashes from raw IR JSON bytes.
-- Returns a Map of plugin namespace → composite hash.
extractPluginHashesFromBytes :: BS.ByteString -> Map Text Text
extractPluginHashesFromBytes bytes =
  case Aeson.decodeStrict bytes of
    Nothing -> Map.empty
    Just v  -> extractPluginHashesFromValue v

-- | Extract plugin hashes from a parsed IR JSON Value.
extractPluginHashesFromValue :: Value -> Map Text Text
extractPluginHashesFromValue v =
  case v of
    Aeson.Object obj ->
      case KM.lookup (AKey.fromText "irPluginHashes") obj of
        Just (Aeson.Object hashesObj) ->
          Map.fromList
            [ (AKey.toText k, extractCompositeHash hv)
            | (k, hv) <- KM.toList hashesObj
            ]
        _ -> Map.empty
    _ -> Map.empty

-- | Pull the "hash" field from a PluginHashInfo JSON object.
extractCompositeHash :: Value -> Text
extractCompositeHash (Aeson.Object o) =
  case KM.lookup (AKey.fromText "hash") o of
    Just (Aeson.String h) -> h
    _                     -> ""
extractCompositeHash _ = ""

-- | Compute which plugin namespaces have changed or been removed vs the cached IR manifest.
-- Returns (changed, removed).
-- If no cache exists, treats all current plugins as changed and none as removed.
diffPluginHashes :: Options -> Text -> Map Text Text -> IO ([Text], [Text])
diffPluginHashes opts bkName freshHashes = do
  cacheDir <- expandTilde (optCacheDir opts)
  let manifestPath = cacheDir </> "synapse" </> "ir" </> T.unpack bkName </> "manifest.json"
  exists <- doesFileExist manifestPath
  if not exists
    then pure (Map.keys freshHashes, [])
    else do
      result <- Aeson.eitherDecodeFileStrict manifestPath :: IO (Either String IRCacheManifest)
      case result of
        Left _         -> pure (Map.keys freshHashes, [])
        Right manifest -> do
          let changed = Map.keys $ Map.filterWithKey (isChanged manifest) freshHashes
              removed = Map.keys $ Map.difference (ircmPlugins manifest) freshHashes
          pure (changed, removed)
  where
    isChanged manifest ns newHash =
      case Map.lookup ns (ircmPlugins manifest) of
        Nothing     -> True  -- plugin is new
        Just cached -> ipcSchemaHash cached /= newHash

-- ============================================================================
-- Utilities
-- ============================================================================

-- | Does a plugin namespace match any of the given prefix filters?
-- A prefix "echo" matches "echo", "echo.health", "echo.workspace.repos", etc.
matchesAnyPrefix :: [Text] -> Text -> Bool
matchesAnyPrefix prefixes ns =
  any (\p -> p == ns || (p <> ".") `T.isPrefixOf` ns) prefixes

-- | Build a Config from WatchArgs and a TargetConfig entry.
-- Thin wrapper around 'Config.buildConfigFromTarget' extracting Options from WatchArgs.
buildConfigFromTarget :: WatchArgs -> SynapseConfig -> TargetConfig -> Config
buildConfigFromTarget wa = Config.buildConfigFromTarget (waOptions wa)

-- | Filter targets to those with "plugins" or "all" in generate list,
-- optionally pinning to a single named target (--target flag).
filterTargets :: WatchArgs -> Map Text TargetConfig -> Map Text TargetConfig
filterTargets watchArgs allTargets =
  let hasPlugins tc = "plugins" `elem` tcGenerate tc || "all" `elem` tcGenerate tc
  in case waTarget watchArgs of
    Just name -> maybe Map.empty (Map.singleton name) (Map.lookup name allTargets)
    Nothing   -> Map.filter hasPlugins allTargets

-- | Synthesize a SynapseConfig from CLI args alone (no config file)
synapseConfigFromArgs :: WatchArgs -> SynapseConfig
synapseConfigFromArgs watchArgs =
  defaultSynapseConfig { scBackend = Just (waBackend watchArgs) }

-- | Parse host and port from a WebSocket URL. Alias for 'Config.parseWsUrl'.
parseUrl :: Text -> (Text, Text)
parseUrl = Config.parseWsUrl