packages feed

seihou-cli-0.14.0.0: src/Seihou/CLI/InstallClosure.hs

-- | Acquisition is completed and validated before any installed entry changes.
module Seihou.CLI.InstallClosure (installClosure, installClosureInto, stageClosuresInto, ClosureArtifact (..), normalizePayload, writeStagedText, publishStagedClosure) where

import Control.Exception (IOException, displayException, mask, onException, throwIO, try)
import Control.Monad (forM, forM_, unless, when)
import Data.Generics.Labels ()
import Data.IORef
import Data.List (find)
import Data.Map.Strict qualified as Map
import Data.Maybe (maybeToList)
import Data.Text qualified as T
import Data.Text.IO qualified as TIO
import Seihou.CLI.InstallShared
import Seihou.CLI.ValidateRecipeReport (recipeLoadErrorText)
import Seihou.Composition.RecipeChain (loadRecipeChain)
import Seihou.Core.AgentPrompt (validateAgentPrompt)
import Seihou.Core.ArtifactIdentity (normalizeOriginUrl)
import Seihou.Core.Blueprint (validateBlueprintForInstall)
import Seihou.Core.Module (validateModule)
import Seihou.Core.Recipe (validateRecipe, validateRecipeVersion)
import Seihou.Core.Registry
import Seihou.Core.Types
import Seihou.Dhall.Eval
import Seihou.Prelude
import System.Directory
import System.Exit (ExitCode (..))
import System.FilePath (isAbsolute, makeRelative, splitDirectories)
import System.IO.Temp (createTempDirectory, withSystemTempDirectory)
import System.Process (readProcessWithExitCode)

-- Paths refer to temporary checkouts until every selected root passes preflight.
data Entry = Entry
  { entryName :: !Text,
    entrySource :: !Text,
    entryDir :: !FilePath,
    entryFile :: !FilePath,
    entryVersion :: !(Maybe Text),
    entryRegistry :: !(Maybe Text),
    entryTags :: ![Text],
    entryChain :: !Text
  }
  deriving stock (Generic)

-- | Normalized artifacts in the caller-owned staging directory. No temporary
-- checkout paths escape; revision and provenance are captured before cleanup.
data ClosureArtifact = ClosureArtifact
  { name :: !Text,
    sourceUrl :: !Text,
    repoName :: !(Maybe Text),
    version :: !(Maybe Text),
    tags :: ![Text],
    sourceRevision :: !(Maybe Text),
    directory :: !FilePath
  }
  deriving stock (Eq, Show, Generic)

data Repo = Repo FilePath Text RepoContents

data ExpectedArtifact = AnyArtifact | ModuleArtifact | RecipeArtifact RecipeReference

data Requirement = Requirement Text (Maybe Text) ExpectedArtifact

moduleRequirements :: [Dependency] -> [Requirement]
moduleRequirements = map (\dep -> Requirement (dep ^. #module_ . #unModuleName) (dep ^. #source) ModuleArtifact)

-- | Roots are registry entries, or a name and directory for a single artifact.
-- All failures discovered across selected roots are reported before staging.
installClosure :: Bool -> FilePath -> Text -> [(Text, FilePath)] -> IO (Either Text ())
installClosure force rootDir recorded roots = do
  cache <- installedRoot
  installClosureInto cache force rootDir recorded roots

-- | Explicit cache root for isolated callers and concurrent tests.
installClosureInto :: FilePath -> Bool -> FilePath -> Text -> [(Text, FilePath)] -> IO (Either Text ())
installClosureInto cache force rootDir recorded roots =
  fmap (fmap (const ())) $
    stageClosuresInto True cache force [(rootDir, recorded, roots)]

-- | The acquisition planner is shared by install and read-only candidate work.
-- The caller chooses an isolated directory for previews; publication into the
-- real cache remains the accepting command's transaction.
stageClosuresInto :: Bool -> FilePath -> Bool -> [(FilePath, Text, [(Text, FilePath)])] -> IO (Either Text [ClosureArtifact])
stageClosuresInto report cache force groups = withSystemTempDirectory "seihou-closure" $ \tmp -> do
  rootRepos <- forM groups $ \(rootDir, recorded, roots) -> do
    contents <- readContents rootDir
    pure (Repo rootDir recorded contents, roots)
  repos <- newIORef (Map.fromList [(normalizeOriginUrl recorded, repo) | (repo@(Repo _ recorded _), _) <- rootRepos])
  nodes <- newIORef Map.empty
  ordered <- newIORef []
  let failWith = throwIO . userError . T.unpack
      getRepo source = do
        known <- readIORef repos
        case Map.lookup (normalizeOriginUrl source) known of
          Just repo -> pure repo
          Nothing -> do
            let dir = tmp </> ("repo-" ++ show (Map.size known))
            cloneRepo source dir >>= either failWith pure
            origin <- recordedSourceUrl <$> resolveRecordedSource source
            discovered <- readContents dir
            let repo = Repo dir origin discovered
            modifyIORef' repos (Map.insert (normalizeOriginUrl source) repo . Map.insert (normalizeOriginUrl origin) repo)
            pure repo
      visit active chain repo@(Repo repoDir origin _) name dir expectedArtifact = do
        let key = (normalizeOriginUrl origin, name)
            context = chain <> name <> " [" <> origin <> "]"
        unless (safeName name) $ failWith (context <> ": unsafe install name")
        when (key `elem` active) $ failWith (context <> ": dependency cycle")
        known <- readIORef nodes
        case Map.lookup name known of
          Just (other, _) | other /= fst key -> failWith (context <> ": conflicting sources for " <> name <> "; also required from " <> other)
          _ -> pure ()
        checkTree repoDir dir
        (file, deps, version) <- inspect dir expectedArtifact `withContext` context
        case Map.lookup name known of
          Just _ -> pure ()
          Nothing -> do
            modifyIORef' nodes (Map.insert name (fst key, context))
            -- Evaluate every declaration independently of generation bindings.
            forM_ deps $ \(Requirement depName source expected) -> do
              depRepo <- case source of
                Nothing -> pure repo
                Just src -> do
                  when (T.null (T.strip src) || "${" `T.isInfixOf` src) $ failWith (context <> ": invalid dependency source")
                  getRepo src `withContext` context
              depDir <- locate depRepo depName expected `withContext` context
              visit (key : active) (context <> " -> ") depRepo depName depDir expected
            let tags = case repo of
                  Repo _ _ (MultiModule reg) -> maybe [] (^. #tags) (find ((== name) . (^. #name . #unModuleName)) (entries reg))
                  _ -> []
                registry = case repo of
                  Repo _ _ (MultiModule reg) -> Just (reg ^. #repoName)
                  _ -> Nothing
            checkTree repoDir dir
            modifyIORef' ordered (++ [Entry name origin dir file version registry tags context])
  outcomes <- fmap concat $ forM rootRepos $ \(repo, roots) ->
    forM roots $ \(name, dir) -> try @IOException (visit [] "" repo name dir AnyArtifact)
  let repairs = T.concat ["\nRetry: seihou install " <> recorded <> T.concat [" --module " <> name | (name, _) <- roots] | (_, recorded, roots) <- groups]
  let errors = [T.pack (displayException e) | Left e <- outcomes]
  if not (null errors)
    then pure (Left (T.intercalate "\n" errors <> repairs))
    else do
      planned <- readIORef ordered
      collisions <- forM planned $ \e -> classifyInstallCollision (cache </> T.unpack (e ^. #entryName)) (e ^. #entrySource)
      let refusals = [(e ^. #entryChain) <> "\n" <> formatInstallRefusal (T.unpack (e ^. #entryName)) (e ^. #entrySource) c | (e, c) <- zip planned collisions, blocked c, not force]
      if not (null refusals)
        then pure (Left (T.intercalate "\n" refusals))
        else do
          result <- try @IOException $ do
            -- Normalize all imports before the source checkouts disappear.
            forM_ planned $ \e -> do
              let stage = tmp </> "stage" </> T.unpack (e ^. #entryName)
              createDirectoryIfMissing True stage
              copyDirectoryRecursive (e ^. #entryDir) stage
              normalizePayload (e ^. #entryDir) stage (e ^. #entryFile)
              definition <- normalizeInstalledDefinition (e ^. #entrySource) ((e ^. #entryDir) </> (e ^. #entryFile))
              writeStagedText (stage </> (e ^. #entryFile)) definition
            -- Build metadata with the existing writer, entirely outside the cache.
            forM_ planned $ \e -> do
              let name = T.unpack (e ^. #entryName)
              _ <- installModuleDirInto (tmp </> "ready") False (tmp </> "stage" </> name) name (e ^. #entrySource) (e ^. #entryRegistry) (e ^. #entryVersion) (e ^. #entryTags)
              pure ()
            -- Validate every merged recipe against only this staged closure.
            -- No ambient cache entry can hide a missing or contradictory edge.
            forM_ planned $ \e -> when (e ^. #entryFile == "recipe.dhall") $ do
              let ready = tmp </> "ready"
                  dir = ready </> T.unpack (e ^. #entryName)
              recipe <- evalRecipeFromFile (dir </> "recipe.dhall") >>= decoded
              unless (recipe ^. #name . #unRecipeName == e ^. #entryName) $
                failWith ("Recipe descriptor name must match registry entry '" <> e ^. #entryName <> "'")
              _ <- loadRecipeChain tmp [ready] recipe dir >>= either (failWith . recipeLoadErrorText) pure
              pure ()
            current <- forM planned $ \e -> classifyInstallCollision (cache </> T.unpack (e ^. #entryName)) (e ^. #entrySource)
            unless (current == collisions) $ failWith "Install cache changed during preparation; retry the same install command."
            createDirectoryIfMissing True cache
            -- Keep recovery data on the same filesystem as the destination.
            transaction <- createTempDirectory cache ".seihou-transaction"
            let commit = do
                  changed <- newIORef []
                  let rollback =
                        readIORef changed
                          >>= mapM_
                            ( \(dest, backup, existed) -> do
                                present <- doesDirectoryExist dest
                                when present (removeDirectoryRecursive dest)
                                when existed (renameDirectory backup dest)
                            )
                  mask $ \restore ->
                    ( forM_ (zip planned collisions) $ \(e, c) -> do
                        let name = T.unpack (e ^. #entryName)
                            dest = cache </> name
                            backup = transaction </> name
                        latest <- classifyInstallCollision dest (e ^. #entrySource)
                        unless (latest == c) $ failWith ("Install cache changed for " <> (e ^. #entryName) <> "; retry.")
                        when (blocked c) $ TIO.putStrLn (formatInstallOverride name (e ^. #entrySource) c)
                        exists <- doesDirectoryExist dest
                        when exists (renameDirectory dest backup)
                        modifyIORef' changed ((dest, backup, exists) :)
                        createDirectoryIfMissing True dest
                        restore (copyDirectoryRecursive (tmp </> "ready" </> name) dest)
                    )
                      `onException` rollback
            commit `onException` TIO.putStrLn ("Install failed; any retained recovery data is in " <> T.pack transaction)
            removeDirectoryRecursive transaction
          case result of
            Left e -> pure (Left (T.pack (displayException e)))
            Right () -> do
              when report $ do
                forM_ planned $ \e -> TIO.putStrLn $ "  Installed " <> (e ^. #entryName) <> " from " <> (e ^. #entrySource) <> if (e ^. #entryName) `elem` [name | (_, _, roots) <- groups, (name, _) <- roots] then " (selected)" else " (required)"
                TIO.putStrLn $ T.pack (show (length planned)) <> " entries installed; selected artifacts are ready."
              artifacts <- forM planned $ \e -> do
                revision <- try @IOException (readProcessWithExitCode "git" ["-C", e ^. #entryDir, "rev-parse", "HEAD"] "")
                pure
                  ( ClosureArtifact
                      (e ^. #entryName)
                      (e ^. #entrySource)
                      (e ^. #entryRegistry)
                      (e ^. #entryVersion)
                      (e ^. #entryTags)
                      (case revision of Right (ExitSuccess, out, _) -> Just (T.strip (T.pack out)); _ -> Nothing)
                      (cache </> T.unpack (e ^. #entryName))
                  )
              pure (Right artifacts)

withContext :: IO a -> Text -> IO a
withContext action context = do
  result <- try @IOException action
  either (throwIO . userError . ((T.unpack context <> " -> ") <>) . displayException) pure result

blocked :: InstallCollision -> Bool
blocked (DifferentSource _) = True
blocked UnknownSource = True
blocked _ = False

safeName :: Text -> Bool
safeName name = not (T.null name) && name /= "." && name /= ".." && T.all (\c -> c /= '/' && c /= '\\' && c /= '\0') name

entries :: Registry -> [RegistryEntry]
entries reg = reg ^. #modules ++ reg ^. #recipes ++ reg ^. #blueprints ++ reg ^. #prompts

locate :: Repo -> Text -> ExpectedArtifact -> IO FilePath
locate (Repo dir origin contents) name expected = case contents of
  MultiModule reg -> case find ((== name) . (^. #name . #unModuleName)) (allowedEntries reg) of
    Just entry -> do
      let candidate = dir </> (entry ^. #path)
      checkTree dir candidate
      pure candidate
    Nothing -> missing
  SingleModule _ -> do
    m <- evalModuleFromFile (dir </> "module.dhall") >>= decoded
    if m ^. #name . #unModuleName == name && isModule then pure dir else missing
  SingleRecipe _ -> do
    r <- evalRecipeFromFile (dir </> "recipe.dhall") >>= decoded
    if r ^. #name . #unRecipeName == name && isRecipe then pure dir else missing
  _ -> missing
  where
    allowedEntries reg = case expected of
      AnyArtifact -> entries reg
      ModuleArtifact -> reg ^. #modules
      RecipeArtifact _ -> reg ^. #recipes
    isModule = case expected of RecipeArtifact _ -> False; _ -> True
    isRecipe = case expected of ModuleArtifact -> False; _ -> True
    kind = case expected of RecipeArtifact _ -> "recipe"; ModuleArtifact -> "module"; AnyArtifact -> "artifact"
    missing = throwIO (userError (T.unpack (name <> " not found as a " <> kind <> " in " <> origin)))

-- Reject escapes and symlinks rather than copying files from outside the clone.
checkTree :: FilePath -> FilePath -> IO ()
checkTree root candidate = do
  root' <- canonicalizePath root
  path <- canonicalizePath candidate
  let rel = makeRelative root' path
  when (isAbsolute rel || ".." `elem` splitDirectories rel) $ throwIO (userError ("Artifact path escapes repository: " <> candidate))
  walk candidate
  where
    walk path = do
      link <- pathIsSymbolicLink path
      when link $ throwIO (userError ("Symbolic links are not supported in installed payloads: " <> path))
      dir <- doesDirectoryExist path
      when dir $ listDirectory path >>= mapM_ (\name -> unless (name == ".git") (walk (path </> name)))

decoded :: (Show e) => Either e a -> IO a
decoded = either (throwIO . userError . show) pure

inspect :: FilePath -> ExpectedArtifact -> IO (FilePath, [Requirement], Maybe Text)
inspect dir expected = do
  hasModule <- doesFileExist (dir </> "module.dhall")
  hasRecipe <- doesFileExist (dir </> "recipe.dhall")
  case expected of
    ModuleArtifact | not hasModule -> wrong "module"
    RecipeArtifact _ | not hasRecipe -> wrong "recipe"
    _ -> pure ()
  case expected of
    RecipeArtifact ref -> recipe (Just ref)
    _ | hasModule -> do
      m <- evalModuleFromFile (dir </> "module.dhall") >>= decoded
      _ <- validateModule dir m >>= decoded
      pure ("module.dhall", moduleRequirements (m ^. #dependencies), m ^. #version)
    _ | hasRecipe -> recipe Nothing
    _ -> do
      blueprint <- doesFileExist (dir </> "blueprint.dhall")
      if blueprint
        then do
          b <- evalBlueprintFromFile (dir </> "blueprint.dhall") >>= decoded
          _ <- validateBlueprintForInstall dir b >>= decoded
          pure ("blueprint.dhall", moduleRequirements (b ^. #baseModules), b ^. #version)
        else do
          p <- evalAgentPromptFromFile (dir </> "prompt.dhall") >>= decoded
          _ <- validateAgentPrompt dir p >>= decoded
          pure ("prompt.dhall", [], p ^. #version)
  where
    wrong kind = throwIO (userError ("Expected " <> kind <> " at " <> dir))
    recipe requirement = do
      r <- evalRecipeFromFile (dir </> "recipe.dhall") >>= decoded
      _ <- decoded (validateRecipe r)
      forM_ requirement $ \ref -> decoded (validateRecipeVersion ref r)
      let base = [Requirement (ref ^. #recipe . #unRecipeName) (ref ^. #source) (RecipeArtifact ref) | ref <- maybeToList (r ^. #extends)]
      pure ("recipe.dhall", base <> moduleRequirements (r ^. #modules), r ^. #version)

-- A malformed registry must not silently fall back to a stray root module.
readContents :: FilePath -> IO RepoContents
readContents dir = do
  registry <- doesFileExist (dir </> "seihou-registry.dhall")
  if registry
    then MultiModule <$> (evalRegistryFromFile (dir </> "seihou-registry.dhall") >>= decoded)
    else discoverRepoContents evalRegistryFromFile dir

-- Dhall source files used by generation can also import siblings of an artifact.
normalizePayload :: FilePath -> FilePath -> FilePath -> IO ()
normalizePayload sourceDir stagedDir definition = do
  children <- listDirectory sourceDir
  forM_ children $ \name -> unless (name == ".git" || name == definition) $ do
    let src = sourceDir </> name
        dst = stagedDir </> name
    directory <- doesDirectoryExist src
    if directory
      then normalizePayload src dst ""
      else when (".dhall" `T.isSuffixOf` T.pack name) $ normalizeDhallFile src >>= writeStagedText dst

-- | Normalization replaces only disposable staging bytes. A copied source may
-- be read-only (including immutable schema imports); do not change its original
-- permissions or lose executable bits merely to write the normalized copy.
writeStagedText :: FilePath -> Text -> IO ()
writeStagedText destination content = do
  permissions <- getPermissions destination
  setPermissions destination (setOwnerWritable True permissions)
  TIO.writeFile destination content

-- | Publish already validated staged bytes atomically across cache entries on
-- handled failures. No acquisition or project writes occur here.
publishStagedClosure :: FilePath -> [ClosureArtifact] -> IO (Either Text ())
publishStagedClosure cache artifacts = do
  collisions <- forM artifacts $ \artifact -> classifyInstallCollision (cache </> T.unpack (artifact ^. #name)) (artifact ^. #sourceUrl)
  let refused = [formatInstallRefusal (T.unpack (artifact ^. #name)) (artifact ^. #sourceUrl) collision | (artifact, collision) <- zip artifacts collisions, blocked collision]
  if not (null refused)
    then pure (Left (T.intercalate "\n" refused))
    else do
      result <- try @IOException $ do
        createDirectoryIfMissing True cache
        transaction <- createTempDirectory cache ".seihou-transaction"
        changed <- newIORef []
        let rollback =
              readIORef changed
                >>= mapM_
                  ( \(dest, backup, existed) -> do
                      present <- doesDirectoryExist dest
                      when present (removeDirectoryRecursive dest)
                      when existed (renameDirectory backup dest)
                  )
        mask $ \restore ->
          ( forM_ (zip artifacts collisions) $ \(artifact, collision) -> do
              let name = T.unpack (artifact ^. #name)
                  dest = cache </> name
                  backup = transaction </> name
              current <- classifyInstallCollision dest (artifact ^. #sourceUrl)
              unless (current == collision) $ throwIO (userError "Install cache changed during recipe upgrade; retry the same upgrade command.")
              exists <- doesDirectoryExist dest
              when exists (renameDirectory dest backup)
              modifyIORef' changed ((dest, backup, exists) :)
              restore $ do
                createDirectoryIfMissing True dest
                copyDirectoryRecursive (artifact ^. #directory) dest
          )
            `onException` rollback
        removeDirectoryRecursive transaction
      pure (first (T.pack . displayException) result)