seihou-cli-0.16.0.0: src/Seihou/CLI/InstalledRunnables.hs
-- | Refreshing installed blueprints, prompts, and judgements from the source each one
-- records in its @.seihou-origin.json@.
--
-- A blueprint is installed together with its @baseModules@ (its install
-- closure), after validation and import normalization. Refreshing it
-- therefore restages that whole closure exactly as @seihou install@ would,
-- compares the staged bytes with the installed ones by content hash, and
-- publishes only what changed. Publication honours ADR 0006: a cache entry in
-- the closure whose provenance names a different source, or none, is refused
-- and the cache is left untouched.
module Seihou.CLI.InstalledRunnables
( RefreshOptions (..),
selectInstalledRunnables,
installKey,
matchesRequest,
refreshInstalledRunnables,
checkInstalledRunnablesForUpdates,
)
where
import Control.Exception (SomeException, displayException, try)
import Control.Monad (forM, when)
import Data.Generics.Labels ()
import Data.List (find)
import Data.Map.Strict qualified as Map
import Data.Set qualified as Set
import Data.Text qualified as T
import Data.Text.IO qualified as TIO
import Seihou.CLI.InstallClosure (ClosureArtifact (..), publishStagedClosure, readContents, stageClosuresInto)
import Seihou.CLI.InstallShared (InstallCollision (..), OriginInfo (..), classifyInstallCollision, cloneRepo, readOriginInfo, summarizeInstallRefusal)
import Seihou.CLI.RemoteVersion (fetchTrueRunnableVersion)
import Seihou.CLI.Update.Source (hashArtifactDirectory)
import Seihou.CLI.UpgradeReport (UpgradeEntry (..), UpgradeStatus (..))
import Seihou.CLI.VersionCompare (OutdatedEntry (..), OutdatedStatus (..), compareVersions)
import Seihou.Core.ArtifactIdentity (normalizeOriginUrl)
import Seihou.Core.Install (parseModuleName)
import Seihou.Core.Module (DiscoveredRunnable (..), ModuleSource (..), RunnableKind (..))
import Seihou.Core.Registry (Registry (..), RegistryEntry (..), RepoContents (..))
import Seihou.Core.Types (ModuleName (..))
import Seihou.Prelude
import System.Directory (doesDirectoryExist)
import System.FilePath (takeFileName)
import System.IO.Temp (withSystemTempDirectory)
data RefreshOptions = RefreshOptions
{ dryRun :: !Bool,
skipUnversioned :: !Bool
}
deriving stock (Eq, Show, Generic)
-- | Installed blueprints, prompts, and judgements, each paired with its
-- recorded origin.
-- Entries whose descriptor fails to load are kept: a broken installed copy is
-- exactly what a refresh can repair.
selectInstalledRunnables :: [DiscoveredRunnable] -> IO [(DiscoveredRunnable, Maybe OriginInfo)]
selectInstalledRunnables runnables =
forM [r | r <- runnables, r ^. #source == SourceInstalled, isRefreshable (r ^. #kind)] $ \r -> do
origin <- readOriginInfo (r ^. #dir)
pure (r, origin)
where
isRefreshable KindBlueprint = True
isRefreshable KindPrompt = True
isRefreshable KindJudgement = True
isRefreshable _ = False
-- | The install-cache key: the entry's directory name under @installed/@.
-- It usually equals the descriptor's name but is not guaranteed to.
installKey :: DiscoveredRunnable -> Text
installKey r = T.pack (takeFileName (r ^. #dir))
-- | A requested name selects an entry by its descriptor name or its key.
matchesRequest :: Text -> DiscoveredRunnable -> Bool
matchesRequest requested r = requested == r ^. #name || requested == installKey r
-- | Refresh each selected root's closure from its recorded source. Returns the
-- report rows (deduplicated by name, first wins) and the names of every
-- artifact in every staged closure, so a caller can keep another refresh path
-- from touching the same entries twice. Prints nothing.
refreshInstalledRunnables :: RefreshOptions -> FilePath -> [(DiscoveredRunnable, OriginInfo)] -> IO ([UpgradeEntry], [Text])
refreshInstalledRunnables opts cache inputs = do
let groups = Map.elems (Map.fromListWith (flip (<>)) [(normalizeOriginUrl (origin ^. #sourceUrl), [(r, origin)]) | (r, origin) <- inputs])
results <- forM groups (refreshGroup opts cache)
let rows = concatMap fst results
names = concatMap snd results
pure (dedupeByName rows, Set.toList (Set.fromList names))
-- | Every member of a group shares one source URL, so one clone serves all.
refreshGroup :: RefreshOptions -> FilePath -> [(DiscoveredRunnable, OriginInfo)] -> IO ([UpgradeEntry], [Text])
refreshGroup _ _ [] = pure ([], [])
refreshGroup opts cache members@((_, firstOrigin) : _) =
withSystemTempDirectory "seihou-runnable-upgrade" $ \tmp -> do
let url = firstOrigin ^. #sourceUrl
cloneDir = tmp </> "clone"
cloned <- cloneRepo url cloneDir
case cloned of
Left _ -> pure ([UpgradeEntry (installKey r) (origin ^. #version) Nothing SourceUnreachable | (r, origin) <- members], [])
Right () -> do
contents <- try @SomeException (readContents cloneDir)
case contents of
Left err ->
let reason = "could not read the registry in " <> url <> ": " <> T.pack (displayException err)
in pure ([failedRow r origin reason | (r, origin) <- members], [])
Right repo -> do
results <- forM (zip [0 :: Int ..] members) $ \(index, member) ->
refreshMember opts cache (tmp </> ("staging-" <> show index)) cloneDir url repo member
pure (concatMap fst results, concatMap snd results)
refreshMember :: RefreshOptions -> FilePath -> FilePath -> FilePath -> Text -> RepoContents -> (DiscoveredRunnable, OriginInfo) -> IO ([UpgradeEntry], [Text])
refreshMember opts cache staging cloneDir url repo (r, origin) =
case locateArtifact cloneDir repo (r ^. #kind) (installKey r) of
Nothing -> pure ([failedRow r origin (kindLabel (r ^. #kind) <> " '" <> installKey r <> "' not found in " <> url)], [])
Just artifactDir -> do
-- The staging cache is empty, so staging itself can never refuse; the
-- ADR 0006 check happens against the real cache at publication.
staged <- stageClosuresInto False staging False [(cloneDir, url, [(installKey r, artifactDir)])]
case staged of
Left reason -> pure ([failedRow r origin reason], [])
Right artifacts -> do
rows <- compareAndPublish opts cache artifacts
pure (rows, map (^. #name) artifacts)
-- | Decide each staged artifact's status as recipe upgrades do, keep a
-- skipped closure together, and publish the changed artifacts atomically.
compareAndPublish :: RefreshOptions -> FilePath -> [ClosureArtifact] -> IO [UpgradeEntry]
compareAndPublish opts cache artifacts = do
compared <- forM artifacts $ \artifact -> do
let path = cache </> T.unpack (artifact ^. #name)
old <- readOriginInfo path
present <- doesDirectoryExist path
oldHash <- if present then Just <$> hashArtifactDirectory path else pure Nothing
newHash <- hashArtifactDirectory (artifact ^. #directory)
let changed = oldHash /= Just newHash
skipped = opts ^. #skipUnversioned && (artifact ^. #version == Nothing || maybe False ((== Nothing) . (^. #version)) old)
status
| changed && skipped = Skipped
| changed = Upgraded
| otherwise = AlreadyUpToDate
pure (UpgradeEntry (artifact ^. #name) (old >>= (^. #version)) (artifact ^. #version) status, artifact)
let blocked = any ((== Skipped) . (^. #upgradeStatus) . fst) compared
settled = [(if blocked && entry ^. #upgradeStatus == Upgraded then entry & #upgradeStatus .~ Skipped else entry, artifact) | (entry, artifact) <- compared]
changes = [artifact | (entry, artifact) <- settled, entry ^. #upgradeStatus == Upgraded]
-- Classify first so each refused row carries its own one-line reason. The
-- closure is published all or nothing, so one refusal holds back the rest.
collisions <- forM changes $ \artifact -> do
collision <- classifyInstallCollision (cache </> T.unpack (artifact ^. #name)) (artifact ^. #sourceUrl)
pure (artifact ^. #name, summarize artifact collision)
let refusals = Map.fromList [(name, reason) | (name, Just reason) <- collisions]
entries = map fst settled
if not (Map.null refusals)
then pure [refuse refusals entry | entry <- entries]
else
if opts ^. #dryRun || null changes
then pure entries
else do
published <- publishStagedClosure cache changes
pure $ case published of
Right () -> entries
Left reason -> [if entry ^. #upgradeStatus == Upgraded then entry & #upgradeStatus .~ UpgradeFailed reason else entry | entry <- entries]
where
summarize artifact = \case
collision@(DifferentSource _) -> Just (summarizeInstallRefusal (artifact ^. #sourceUrl) collision)
UnknownSource -> Just (summarizeInstallRefusal (artifact ^. #sourceUrl) UnknownSource)
_ -> Nothing
refuse refusals entry
| entry ^. #upgradeStatus /= Upgraded = entry
| Just reason <- Map.lookup (entry ^. #moduleName) refusals = entry & #upgradeStatus .~ UpgradeFailed reason
| otherwise =
entry
& #upgradeStatus
.~ UpgradeFailed ("not published: " <> T.intercalate ", " (Map.keys refusals) <> " in the same closure was refused")
-- | Compare each installed blueprint's or prompt's recorded version with the
-- version its source's descriptor declares. Reads the source only and never
-- stages, so unlike 'refreshInstalledRunnables' it compares declared versions,
-- not content. Clones once per source URL; prints @Cloning@ progress lines when
-- asked. A failed clone is 'Unreachable'; a lookup error collapses to an
-- unknown available version, shown as unversioned.
checkInstalledRunnablesForUpdates :: Bool -> [(DiscoveredRunnable, OriginInfo)] -> IO [OutdatedEntry]
checkInstalledRunnablesForUpdates progress inputs = do
let groups = Map.elems (Map.fromListWith (flip (<>)) [(normalizeOriginUrl (origin ^. #sourceUrl), [(r, origin)]) | (r, origin) <- inputs])
concat <$> forM groups checkGroup
where
checkGroup [] = pure []
checkGroup members@((_, firstOrigin) : _) = do
let url = firstOrigin ^. #sourceUrl
when progress $ TIO.putStrLn (" Cloning " <> T.pack (parseModuleName url) <> "...")
withSystemTempDirectory "seihou-runnable-outdated" $ \tmp -> do
let cloneDir = tmp </> "clone"
cloned <- cloneRepo url cloneDir
forM members $ \(r, origin) -> case cloned of
Left _ -> pure (OutdatedEntry (installKey r) (origin ^. #version) Nothing Unreachable)
Right () -> do
available <- either (const Nothing) id <$> fetchTrueRunnableVersion cloneDir (r ^. #kind) (installKey r)
pure (OutdatedEntry (installKey r) (origin ^. #version) available (compareVersions (origin ^. #version) available))
-- | Find a blueprint, prompt, or judgement in a cloned source, by the key it is installed
-- under.
locateArtifact :: FilePath -> RepoContents -> RunnableKind -> Text -> Maybe FilePath
locateArtifact cloneDir contents kind key = case (contents, kind) of
(MultiModule registry, KindBlueprint) -> byName (registry ^. #blueprints)
(MultiModule registry, KindPrompt) -> byName (registry ^. #prompts)
(MultiModule registry, KindJudgement) -> byName (registry ^. #judgements)
(SingleBlueprint root, KindBlueprint) -> Just root
(SinglePrompt root, KindPrompt) -> Just root
(SingleJudgement root, KindJudgement) -> Just root
_ -> Nothing
where
byName = fmap (\entry -> cloneDir </> entry ^. #path) . find (\entry -> entry ^. #name . #unModuleName == key)
kindLabel :: RunnableKind -> Text
kindLabel = \case
KindBlueprint -> "blueprint"
KindPrompt -> "prompt"
KindJudgement -> "judgement"
KindRecipe -> "recipe"
KindModule -> "module"
failedRow :: DiscoveredRunnable -> OriginInfo -> Text -> UpgradeEntry
failedRow r origin reason = UpgradeEntry (installKey r) (origin ^. #version) Nothing (UpgradeFailed reason)
dedupeByName :: [UpgradeEntry] -> [UpgradeEntry]
dedupeByName = go Set.empty
where
go _ [] = []
go seen (entry : rest)
| Set.member (entry ^. #moduleName) seen = go seen rest
| otherwise = entry : go (Set.insert (entry ^. #moduleName) seen) rest