packages feed

pms-infra-agent-process-0.0.2.0: src/PMS/Infra/Agent/Process/DS/Core.hs

{-# LANGUAGE TemplateHaskell #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE FlexibleContexts #-}

module PMS.Infra.Agent.Process.DS.Core where

import System.IO
import Control.Monad.Logger
import Control.Monad.IO.Class
import Control.Lens
import Control.Monad.Reader
import Control.Concurrent.Async
import qualified Control.Concurrent.STM as STM
import Data.Conduit
import qualified Data.Text as T
import Control.Monad.Except
import Control.Monad (when)
import qualified Control.Exception.Safe as E
import System.Exit
import Data.Aeson
import qualified System.Process as S
import qualified Data.Map.Strict as Map

import qualified PMS.Domain.Model.DM.Type as DM
import qualified PMS.Domain.Model.DM.Constant as DM
import qualified PMS.Domain.Model.DS.Utility as DM

import PMS.Infra.Agent.Process.DM.Type
import qualified PMS.Infra.Agent.Process.DM.Constant as DM_CONST
import PMS.Infra.Agent.Process.DS.Utility


-- |
--
app :: AppContext ()
app = do
  $logDebugS DM._LOGTAG "app called."
  runConduit pipeline
  where
    pipeline :: ConduitM () Void AppContext ()
    pipeline = src .| cmd2task .| sink

---------------------------------------------------------------------------------
-- |
--
src :: ConduitT () DM.AgentProcessCommand AppContext ()
src = lift go >>= yield >> src
  where
    go :: AppContext DM.AgentProcessCommand
    go = do
      queue <- view DM.agentProcessQueueDomainData <$> lift ask
      dat <- liftIO $ STM.atomically $ STM.readTQueue queue
      return dat

---------------------------------------------------------------------------------
-- |
--
cmd2task :: ConduitT DM.AgentProcessCommand (IOTask ()) AppContext ()
cmd2task = await >>= \case
  Just cmd -> flip catchError (errHdl cmd) $ do
    lift (go cmd) >>= yield >> cmd2task
  Nothing -> do
    $logWarnS DM._LOGTAG "cmd2task: await returns nothing. skip."
    cmd2task

  where
    errHdl :: DM.AgentProcessCommand -> String -> ConduitT DM.AgentProcessCommand (IOTask ()) AppContext ()
    errHdl procCmd msg = do
      let jsonrpc = DM.getJsonRpcAgentProcessCommand procCmd
      $logWarnS DM._LOGTAG $ T.pack $ "cmd2task: exception occurred. skip. " ++ msg
      lift $ errorToolsCallResponse jsonrpc $ "cmd2task: exception occurred. skip. " ++ msg
      cmd2task

    go :: DM.AgentProcessCommand -> AppContext (IOTask ())
    go (DM.AgentProcessEchoCommand      dat) = genEchoTask dat
    go (DM.AgentProcessRunCommand       dat) = genProcRunTask dat
    go (DM.AgentProcessReadCommand      dat) = genProcReadTask dat
    go (DM.AgentProcessWriteCommand     dat) = genProcWriteTask dat
    go (DM.AgentProcessTerminateCommand dat) = genProcTerminateTask dat

---------------------------------------------------------------------------------
-- |
--
sink :: ConduitT (IOTask ()) Void AppContext ()
sink = await >>= \case
  Just req -> flip catchError errHdl $ do
    lift (go req) >> sink
  Nothing -> do
    $logWarnS DM._LOGTAG "sink: await returns nothing. skip."
    sink

  where
    errHdl :: String -> ConduitT (IOTask ()) Void AppContext ()
    errHdl msg = do
      $logWarnS DM._LOGTAG $ T.pack $ "sink: exception occurred. skip. " ++ msg
      sink

    go :: (IO ()) -> AppContext ()
    go task = do
      $logDebugS DM._LOGTAG "sink: start async."
      _ <- liftIOE $ async task
      $logDebugS DM._LOGTAG "sink: end async."
      return ()


---------------------------------------------------------------------------------
-- |
--
genEchoTask :: DM.AgentProcessEchoCommandData -> AppContext (IOTask ())
genEchoTask dat = do
  resQ <- view DM.responseQueueDomainData <$> lift ask
  let val = dat^.DM.valueAgentProcessEchoCommandData

  $logDebugS DM._LOGTAG $ T.pack $ "echoTask: echo : " ++ val
  return $ echoTask resQ dat val


-- |
--
echoTask :: STM.TQueue DM.McpResponse -> DM.AgentProcessEchoCommandData -> String -> IOTask ()
echoTask resQ cmdDat val = flip E.catchAny errHdl $ do
  hPutStrLn stderr $ "[INFO] PMS.Infra.Agent.Process.DS.Core.echoTask run. " ++ val
  DM.toolsCallResponse resQ (cmdDat^.DM.jsonrpcAgentProcessEchoCommandData) ExitSuccess val ""
  hPutStrLn stderr "[INFO] PMS.Infra.Agent.Process.DS.Core.echoTask end."

  where
    errHdl :: E.SomeException -> IO ()
    errHdl e = DM.toolsCallResponse resQ (cmdDat^.DM.jsonrpcAgentProcessEchoCommandData) (ExitFailure 1) "" (show e)


---------------------------------------------------------------------------------
-- |
--
genProcRunTask :: DM.AgentProcessRunCommandData -> AppContext (IOTask ())
genProcRunTask cmdDat = do
  let argsBS = DM.unRawJsonByteString $ cmdDat^.DM.argumentsAgentProcessRunCommandData
  resQ      <- view DM.responseQueueDomainData <$> lift ask
  procTMVar <- view processAppData <$> ask
  argsDat   <- liftEither $ eitherDecode argsBS
  let cmd       = argsDat^.commandProcRunToolParams
      argsArray = maybe [] id (argsDat^.argumentsProcRunToolParams)
      envMap    = argsDat^.environmentProcRunToolParams
      addEnv    = maybe [] Map.toList envMap
  -- whitelist check: deny all if list is empty or cmd not in list
  allowedCmds <- view DM.allowedAgentCmdsDomainData <$> lift ask
  when (null allowedCmds || cmd `notElem` allowedCmds) $
    throwError $ "command not allowed: " ++ cmd
  return $ procRunTask cmdDat resQ procTMVar cmd argsArray addEnv


-- |
--
procRunTask :: DM.AgentProcessRunCommandData
            -> STM.TQueue DM.McpResponse
            -> STM.TMVar (Maybe ProcData)
            -> String
            -> [String]
            -> [(String, String)]
            -> IOTask ()
procRunTask cmdDat resQ procTMVar cmd argsArray addEnv = flip E.catchAny errHdl $ do
  hPutStrLn stderr "[INFO] PMS.Infra.Agent.Process.DS.Core.procRunTask run."
  STM.atomically (STM.takeTMVar procTMVar) >>= \case
    Just p -> do
      STM.atomically $ STM.putTMVar procTMVar (Just p)
      hPutStrLn stderr "[ERROR] PMS.Infra.Agent.Process.DS.Core.procRunTask: process is already running."
      DM.toolsCallResponse resQ jsonRpc (ExitFailure 1) "" "process is already running."
    Nothing -> do
      runProc procTMVar cmd argsArray addEnv
      let payload = "command: " ++ truncate10 cmd
                 ++ ", arguments: " ++ truncate10 (show argsArray)
                 ++ ", environment: " ++ truncate10 (show addEnv)
      DM.toolsCallResponse resQ jsonRpc ExitSuccess payload ""
  hPutStrLn stderr "[INFO] PMS.Infra.Agent.Process.DS.Core.procRunTask end."
  where
    jsonRpc = cmdDat^.DM.jsonrpcAgentProcessRunCommandData
    errHdl :: E.SomeException -> IO ()
    errHdl e = do
      _ <- STM.atomically $ STM.tryTakeTMVar procTMVar
      STM.atomically $ STM.putTMVar procTMVar Nothing
      DM.toolsCallResponse resQ jsonRpc (ExitFailure 1) "" (show e)

    -- |
    -- Truncate a string to at most 10 characters, appending "..." if truncated.
    truncate10 :: String -> String
    truncate10 s
      | length s <= 10 = s
      | otherwise      = take 10 s ++ "..."


---------------------------------------------------------------------------------
-- |
-- Generate a task to terminate the running process.
genProcTerminateTask :: DM.AgentProcessTerminateCommandData -> AppContext (IOTask ())
genProcTerminateTask dat = do
  $logDebugS DM._LOGTAG $ T.pack $ "genProcTerminateTask called. "
  procTMVar <- view processAppData <$> ask
  resQ <- view DM.responseQueueDomainData <$> lift ask
  return $ procTerminateTask dat resQ procTMVar

-- |
-- Terminate the running process and reset the TMVar to Nothing.
-- Always returns ExitSuccess if terminateProcess completes without exception.
-- The exit code from waitForProcess is recorded in the log only.
procTerminateTask :: DM.AgentProcessTerminateCommandData
                  -> STM.TQueue DM.McpResponse
                  -> STM.TMVar (Maybe ProcData)
                  -> IOTask ()
procTerminateTask cmdDat resQ procTMVar = flip E.catchAny errHdl $ do
  hPutStrLn stderr $ "[INFO] PMS.Infra.Agent.Process.DS.Core.procTerminateTask run. "
  let jsonRpc = cmdDat^.DM.jsonrpcAgentProcessTerminateCommandData

  STM.atomically (STM.swapTMVar procTMVar Nothing) >>= \case
    Nothing -> do
      hPutStrLn stderr "[ERROR] PMS.Infra.Agent.Process.DS.Core.procTerminateTask: process is not started."
      DM.toolsCallResponse resQ jsonRpc (ExitFailure 1) "" "process is not started."
    Just procDat -> do
      let pHdl = procDat^.pHdlProcData
      S.terminateProcess pHdl
      exitCode <- S.waitForProcess pHdl
      hPutStrLn stderr $ "[INFO] PMS.Infra.Agent.Process.DS.Core.procTerminateTask closeProc : " ++ show exitCode
      DM.toolsCallResponse resQ jsonRpc ExitSuccess "" "process is terminated."

  hPutStrLn stderr "[INFO] PMS.Infra.Agent.Process.DS.Core.procTerminateTask end."

  where
    errHdl :: E.SomeException -> IO ()
    errHdl e = DM.toolsCallResponse resQ (cmdDat^.DM.jsonrpcAgentProcessTerminateCommandData) (ExitFailure 1) "" (show e)


---------------------------------------------------------------------------------
-- |
-- Use DM_CONST._PROC_READ_WAIT_MSEC (100msec) as the timeout value.
-- External config (timeoutMicrosecDomainData) is not used.
genProcReadTask :: DM.AgentProcessReadCommandData -> AppContext (IOTask ())
genProcReadTask cmdData = do
  resQ      <- view DM.responseQueueDomainData <$> lift ask
  procTMVar <- view processAppData <$> ask
  let argsBS     = DM.unRawJsonByteString $ cmdData^.DM.argumentsAgentProcessReadCommandData
  argsDat <- liftEither $ eitherDecode $ argsBS
  let size       = argsDat^.argumentsProcIntToolParams
      actualSize = min size DM_CONST._READ_BUFFER_SIZE
  return $ procReadTask cmdData resQ procTMVar DM_CONST._PROC_READ_WAIT_MSEC actualSize

-- |
--
procReadTask :: DM.AgentProcessReadCommandData
             -> STM.TQueue DM.McpResponse
             -> STM.TMVar (Maybe ProcData)
             -> Int
             -> Int
             -> IOTask ()
procReadTask cmdDat resQ procTMVar tout actualSize = flip E.catchAny errHdl $ do
  hPutStrLn stderr $ "[INFO] PMS.Infra.Agent.Process.DS.Core.procReadTask run. actualSize: " ++ show actualSize

  STM.atomically (STM.readTMVar procTMVar) >>= \case
    Nothing -> do
      hPutStrLn stderr "[ERROR] PMS.Infra.Agent.Process.DS.Core.procReadTask: process is not started."
      DM.toolsCallResponse resQ jsonRpc (ExitFailure 1) "" "process is not started."
    Just p -> do
      output <- DM.readHandle (p^.rHdlProcData) tout actualSize
      let result = DM.bs2strUTF8Strip output
      DM.toolsCallResponse resQ jsonRpc ExitSuccess result ""

  where
    jsonRpc = cmdDat^.DM.jsonrpcAgentProcessReadCommandData
    errHdl e = DM.toolsCallResponse resQ jsonRpc (ExitFailure 1) "" (show e)


---------------------------------------------------------------------------------
-- | CR-12: agent-proc-write with invalidPatterns check.
-- The invalidPatterns check is performed here in the gen function (AppContext),
-- consistent with how genProcRunTask performs the allowedCmds check.
genProcWriteTask :: DM.AgentProcessWriteCommandData -> AppContext (IOTask ())
genProcWriteTask cmdData = do
  let argsBS = DM.unRawJsonByteString $ cmdData^.DM.argumentsAgentProcessWriteCommandData
  resQ            <- view DM.responseQueueDomainData <$> lift ask
  procTMVar       <- view processAppData <$> ask
  invalidPatterns <- view DM.invalidPatternsDomainData <$> lift ask
  argsDat         <- liftEither $ eitherDecode $ argsBS
  let args          = argsDat^.dataProcWriteToolParams
      appendNewline = argsDat^.appendNewlineProcWriteToolParams
  -- CR-12: invalidPatterns check (pure check, consistent with genProcRunTask allowedCmds check)
  case DM.checkInvalidPatterns invalidPatterns args of
    Just pat -> throwError $
      "agent-proc-write: rejected. input matches invalidPatterns: " ++ show pat
    Nothing  -> return ()
  return $ procWriteTask cmdData resQ procTMVar args appendNewline

-- |
--
procWriteTask :: DM.AgentProcessWriteCommandData
              -> STM.TQueue DM.McpResponse
              -> STM.TMVar (Maybe ProcData)
              -> String
              -> Maybe Bool
              -> IOTask ()
procWriteTask cmdDat resQ procTMVar args appendNewline = flip E.catchAny errHdl $ do
  hPutStrLn stderr $ "[INFO] PMS.Infra.Agent.Process.DS.Core.procWriteTask run. " ++ args

  STM.atomically (STM.readTMVar procTMVar) >>= \case
    Nothing -> do
      hPutStrLn stderr "[ERROR] PMS.Infra.Agent.Process.DS.Core.procWriteTask: process is not started."
      DM.toolsCallResponse resQ jsonRpc (ExitFailure 1) "" "process is not started."
    Just p -> do
      -- Apply appendCRLF (auto-append newline) unless appendNewline is explicitly False.
      let payload = if appendNewline == Just False
                      then args
                      else DM.appendCRLF args
      DM.writeHandle (p^.wHdLProcData) (DM.str2bsUTF8 payload)
      DM.toolsCallResponse resQ jsonRpc ExitSuccess "success" ""

  where
    jsonRpc = cmdDat^.DM.jsonrpcAgentProcessWriteCommandData
    errHdl e = DM.toolsCallResponse resQ jsonRpc (ExitFailure 1) "" (show e)