packages feed

shikumi-tools-0.2.0.0: src/Shikumi/Tool/Env.hs

{-# LANGUAGE ConstraintKinds #-}
{-# LANGUAGE RankNTypes #-}

-- | Execution-environment operations for built-in work tools.
--
-- Filesystem and process-backed tools should depend on this record instead of
-- calling the host operating system directly. A future sandbox can provide a
-- different 'ToolEnv' value while the tool definitions stay unchanged.
module Shikumi.Tool.Env
  ( Path,
    EnvRow,
    ExecRequest (..),
    ExecResult (..),
    FileStat (..),
    DirEntry (..),
    ToolEnv (..),
    localToolEnv,
  )
where

import Control.Exception (IOException, try)
import Control.Lens ((^.))
import Data.Aeson (ToJSON)
import Data.ByteString (ByteString)
import Data.ByteString qualified as BS
import Data.Generics.Labels ()
import Data.Text (Text)
import Data.Text qualified as T
import Effectful (Eff, (:>))
import Effectful.Dispatch.Static (unsafeEff_)
import Effectful.Error.Static (Error, throwError)
import GHC.Generics (Generic)
import Shikumi.Error (ShikumiError (..))
import Shikumi.LLM (LLM)
import System.Directory qualified as Dir
import System.Exit (ExitCode (..))
import System.Process qualified as Proc
import System.Timeout qualified as Timeout

type Path = Text

type EnvRow es = (LLM :> es, Error ShikumiError :> es)

data ExecRequest = ExecRequest
  { command :: !Text,
    cwd :: !(Maybe Path),
    stdin :: !(Maybe Text),
    timeoutMs :: !(Maybe Int)
  }
  deriving stock (Generic, Show, Eq)
  deriving anyclass (ToJSON)

data ExecResult = ExecResult
  { exitCode :: !Int,
    stdout :: !Text,
    stderr :: !Text
  }
  deriving stock (Generic, Show, Eq)
  deriving anyclass (ToJSON)

data FileStat = FileStat
  { isFile :: !Bool,
    isDir :: !Bool,
    size :: !Integer
  }
  deriving stock (Generic, Show, Eq)
  deriving anyclass (ToJSON)

data DirEntry = DirEntry
  { name :: !Text,
    isDir :: !Bool
  }
  deriving stock (Generic, Show, Eq)
  deriving anyclass (ToJSON)

data ToolEnv = ToolEnv
  { envExec :: forall es. (EnvRow es) => ExecRequest -> Eff es ExecResult,
    envReadFile :: forall es. (EnvRow es) => Path -> Eff es ByteString,
    envWriteFile :: forall es. (EnvRow es) => Path -> ByteString -> Eff es (),
    envStat :: forall es. (EnvRow es) => Path -> Eff es (Maybe FileStat),
    envReaddir :: forall es. (EnvRow es) => Path -> Eff es [DirEntry],
    envExists :: forall es. (EnvRow es) => Path -> Eff es Bool,
    envMkdir :: forall es. (EnvRow es) => Path -> Eff es (),
    envRm :: forall es. (EnvRow es) => Path -> Eff es (),
    envCwd :: forall es. (EnvRow es) => Eff es Path
  }

localToolEnv :: ToolEnv
localToolEnv =
  ToolEnv
    { envExec = localExec,
      envReadFile = \path -> toolIO "readFile" (BS.readFile (T.unpack path)),
      envWriteFile = \path bytes -> toolIO "writeFile" (BS.writeFile (T.unpack path) bytes),
      envStat = localStat,
      envReaddir = localReaddir,
      envExists = \path -> toolIO "exists" (Dir.doesPathExist (T.unpack path)),
      envMkdir = \path -> toolIO "mkdir" (Dir.createDirectoryIfMissing True (T.unpack path)),
      envRm = localRm,
      envCwd = T.pack <$> toolIO "cwd" Dir.getCurrentDirectory
    }

localExec :: (EnvRow es) => ExecRequest -> Eff es ExecResult
localExec req = do
  let effectiveTimeoutMs = timeoutMsOrDefault (req ^. #timeoutMs)
  result <-
    toolIO "exec" $
      Timeout.timeout (effectiveTimeoutMs * 1000) $
        Proc.readCreateProcessWithExitCode process stdinText
  case result of
    Nothing ->
      throwError $
        Timeout
          ("tool env: exec timed out after " <> T.pack (show effectiveTimeoutMs) <> "ms")
    Just (exit, out, err) ->
      pure
        ExecResult
          { exitCode = exitCodeInt exit,
            stdout = T.pack out,
            stderr = T.pack err
          }
  where
    process =
      (Proc.proc "bash" ["-c", T.unpack (req ^. #command)])
        { Proc.cwd = T.unpack <$> (req ^. #cwd)
        }
    stdinText = maybe "" T.unpack (req ^. #stdin)

localStat :: (EnvRow es) => Path -> Eff es (Maybe FileStat)
localStat path = toolIO "stat" $ do
  let fp = T.unpack path
  file <- Dir.doesFileExist fp
  dir <- Dir.doesDirectoryExist fp
  if file
    then do
      size <- Dir.getFileSize fp
      pure (Just FileStat {isFile = True, isDir = False, size})
    else
      if dir
        then pure (Just FileStat {isFile = False, isDir = True, size = 0})
        else pure Nothing

localReaddir :: (EnvRow es) => Path -> Eff es [DirEntry]
localReaddir path = toolIO "readdir" $ do
  let dir = T.unpack path
  names <- Dir.listDirectory dir
  traverse
    ( \entry -> do
        isDir <- Dir.doesDirectoryExist (dir <> "/" <> entry)
        pure DirEntry {name = T.pack entry, isDir}
    )
    names

localRm :: (EnvRow es) => Path -> Eff es ()
localRm path = toolIO "rm" $ do
  let fp = T.unpack path
  file <- Dir.doesFileExist fp
  dir <- Dir.doesDirectoryExist fp
  if file
    then Dir.removeFile fp
    else
      if dir
        then Dir.removeDirectory fp
        else pure ()

toolIO :: (EnvRow es) => Text -> IO a -> Eff es a
toolIO label action = do
  result <- unsafeEff_ (try action)
  case result of
    Right value -> pure value
    Left (err :: IOException) ->
      throwError (ProviderFailure ("tool env: " <> label <> ": " <> T.pack (show err)))

timeoutMsOrDefault :: Maybe Int -> Int
timeoutMsOrDefault = maybe 60000 id

exitCodeInt :: ExitCode -> Int
exitCodeInt ExitSuccess = 0
exitCodeInt (ExitFailure n) = n